Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 10 additions & 3 deletions app/components/docs_ui/code.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,10 @@ module DocsUI
#
# Any language Rouge knows (~200 lexers) works by its name or alias — python,
# go, rust, elixir, kotlin, swift, json, dockerfile, ... — no allowlist. Add
# friendly aliases/labels via DocsKit.configure (code_lexer_aliases). An unknown
# language falls back to plaintext (never raises).
# friendly lexer aliases via DocsKit.configure (code_lexer_aliases). An unknown
# language falls back to plaintext (never raises). (Tab labels are a
# DocsUI::Example concern — set via code_language_labels, not here; Code has no
# label, only a filename.)
class Code < Phlex::HTML
include Phlex::Rails::Helpers::ContentSecurityPolicyNonce

Expand Down Expand Up @@ -53,7 +55,12 @@ def view_template
def csp_nonce = view_context && content_security_policy_nonce

def title_bar
div(class: "flex items-center gap-2 border-b border-base-300 bg-base-300/60 px-4 py-2") do
# data-md-skip: the title bar is chrome. MarkdownExport strips it whole
# before the visitor runs, so the filename never leaks into the .md twin as
# a stray line above the fence. The visible HTML is unaffected (DROP_SELECTOR
# is applied only inside #to_md).
div(class: "flex items-center gap-2 border-b border-base-300 bg-base-300/60 px-4 py-2",
data: { md_skip: true }) do
render DocsUI::Icon.new("file-code", class: "size-3.5 opacity-60")
span(class: "font-mono text-xs opacity-70") { @filename }
end
Expand Down
9 changes: 7 additions & 2 deletions app/components/docs_ui/error_table.rb
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ class ErrorTable < Phlex::HTML

def initialize(errors)
@errors = errors
@with_param = errors.any? { |error| error[:param] }
@with_param = errors.any? { |error| present_param?(error[:param]) }
end

def view_template
Expand All @@ -49,7 +49,12 @@ def row(error)

def param_cell(error)
param = error[:param]
param ? [:code, param] : NO_PARAM
present_param?(param) ? [:code, param] : NO_PARAM
end

# A blank string is not a param — it flips no column and gets the em-dash.
def present_param?(param)
!param.nil? && !param.to_s.strip.empty?
end
end
end
2 changes: 1 addition & 1 deletion app/components/docs_ui/field_table.rb
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ def row(field)
[:code, field.fetch(:name)],
field.fetch(:type),
field.fetch(:required, false) ? REQUIRED_YES : REQUIRED_NO,
field.fetch(:description)
field.fetch(:description, REQUIRED_NO)
]
end
end
Expand Down
2 changes: 1 addition & 1 deletion app/components/docs_ui/page.rb
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ def eyebrow(value = nil)
# or :panel/:toggle/:sidebar to override per page.
def on_page(value = :__unset__)
@on_page = value unless value == :__unset__
defined?(@on_page) ? @on_page : true
defined?(@on_page) ? @on_page : DocsKit.configuration.on_page_default
end
end

Expand Down
2 changes: 1 addition & 1 deletion app/components/docs_ui/prop_table.rb
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ def view_template
# left as the author wrote it.
def code_first_column(cells)
first, *rest = cells
first = [:code, first] unless first.is_a?(Array)
first = [:code, first] unless first.nil? || first.is_a?(Array)
[first, *rest]
end
end
Expand Down
2 changes: 2 additions & 0 deletions app/components/docs_ui/request_example.rb
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,8 @@ def merged_headers(auth_header)
return @headers if auth_header.nil? || auth_header.strip.empty?

name, value = auth_header.split(":", 2).map(&:strip)
return @headers if value.nil? || value.empty?

{ name => value }.merge(@headers)
end

Expand Down
19 changes: 18 additions & 1 deletion app/components/docs_ui/section.rb
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,14 @@ module DocsUI
class Section < Phlex::HTML
def initialize(title, id: nil, description: nil)
@title = title
@id = id || slugify(title)
@explicit_id = id
@description = description
end

def view_template(&)
# Resolve the anchor id at render time so it can be de-duplicated against
# sibling sections sharing this page's render context (see #resolve_id).
@id = @explicit_id || unique_id(slugify(@title))
section(id: @id, class: "mb-10 scroll-mt-20") do
heading
description
Expand Down Expand Up @@ -73,5 +76,19 @@ def slugify(text)

text.to_s.downcase.gsub(/[^a-z0-9]+/, "-").gsub(/\A-+|-+\z/, "")
end

# De-duplicate the anchor id across every Section on the page. Phlex's render
# `context` is a Hash shared by the whole render tree, so sibling sections see
# the same used-id counter without any shared parent state. A title that
# slugifies to "" (e.g. "C++" → "c" is fine, but "+++" → "") falls back to
# "section"; colliding bases get a "-1", "-2", … sequence suffix so in-page
# anchors and the auto-TOC/scroll-spy resolve to distinct headings.
def unique_id(base)
base = "section" if base.empty?
used = (context[:__docs_ui_section_ids__] ||= Hash.new(0))
n = used[base]
used[base] += 1
n.zero? ? base : "#{base}-#{n}"
end
end
end
2 changes: 1 addition & 1 deletion app/components/docs_ui/sidebar.rb
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ def nav_groups = config.nav_groups

def header_section
div(class: "flex min-h-16 items-center gap-2 px-4") do
a(href: "/", class: "text-lg font-bold text-base-content") { config.brand }
a(href: config.brand_href, class: "text-lg font-bold text-base-content") { config.brand }
badge = config.version_badge_text
span(class: "badge badge-sm badge-ghost") { badge } if badge
end
Expand Down
3 changes: 3 additions & 0 deletions app/components/docs_ui/table.rb
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,9 @@ def render_cell(cell)
case cell
in [:code, value] then code(class: "text-sm") { plain value.to_s }
in [:md, value] then render DocsUI::Markdown.inline(value.to_s)
in [Symbol => _tag, *]
raise ArgumentError,
"DocsUI::Table: unknown or malformed typed cell #{cell.inspect}; use [:code, value] or [:md, value]"
else plain cell.to_s
end
end
Expand Down
5 changes: 4 additions & 1 deletion lib/docs_kit/api_templates.rb
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,10 @@ def python(request)
# The body as compact single-line JSON, for inlining in a JS literal.
def compact_json(request)
require "json"
JSON.generate(JSON.parse(request.pretty_body_json))
json = request.pretty_body_json
JSON.generate(JSON.parse(json))
rescue JSON::ParserError
json
end
end
end
70 changes: 51 additions & 19 deletions lib/docs_kit/configuration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
module DocsKit
# Per-site configuration for the shared docs chrome. Everything that differs
# between two otherwise-identical docs sites lives here, so the Phlex shell
# (Docs::Shell, Docs::Sidebar, Docs::ThemeSwitcher) is byte-identical across
# (DocsUI::Shell, DocsUI::Sidebar, DocsUI::ThemeSwitcher) is byte-identical across
# sites and only the config changes.
#
# DocsKit.configure do |c|
Expand Down Expand Up @@ -42,14 +42,22 @@ class Configuration

# A callable returning the sidebar nav as an ordered Hash of
# { "Heading" => { "Subgroup" => [items] } }. Each item must respond to
# the duck type the Sidebar renders (see Docs::Sidebar#nav_link): #href,
# the duck type the Sidebar renders (see DocsUI::Sidebar#nav_link): #href,
# #label, and optional #icon. Defaults to an empty nav.
#
# Prefer #nav_registries for the common case — an explicit #nav lambda is
# only needed for bespoke nav (multiple registries interleaved, custom
# subgroups). When #nav is left at its default, the sidebar derives from
# #nav_registries instead.
attr_accessor :nav
attr_reader :nav

# Assigning #nav marks it explicit, so #nav_groups uses it verbatim rather
# than deriving from #nav_registries — tracked by a flag, not object
# identity, so ANY assigned lambda wins (even one that resolves to {}).
def nav=(value)
@nav_explicit = true
@nav = value
end

# An ordered { "Heading" => registry_class } map. Each registry responds to
# .nav_items (Registry v2) → { group => [NavItem] } for its authored pages.
Expand All @@ -67,14 +75,14 @@ class Configuration
# stylesheets (e.g. a separate rouge theme) lists them here.
attr_accessor :stylesheets

# The Rouge theme class used by Docs::Code for inline syntax-highlight CSS.
# The Rouge theme class used by DocsUI::Code for inline syntax-highlight CSS.
# This is the BASE (light) theme, emitted un-scoped so it applies to every
# theme unless a dark override wins (see #code_theme_dark).
attr_accessor :code_theme

# An optional second Rouge theme (String name or Class) used for the site's
# DARK daisyUI themes. Default nil → single-theme behavior, fully backwards
# compatible. When set, Docs::Code additionally emits this theme's CSS scoped
# compatible. When set, DocsUI::Code additionally emits this theme's CSS scoped
# under [data-theme=X] .code-highlight for each shipped dark theme (see
# #dark_themes), so code blocks stay readable when the switcher flips to a
# dark theme — CSS-only, no JS, no flash.
Expand Down Expand Up @@ -103,7 +111,7 @@ class Configuration
# the brand.
attr_writer :nav_storage_key

# The default "On this page" (auto-TOC) placement, used by Docs::Page when a
# The default "On this page" (auto-TOC) placement, used by DocsUI::Page when a
# page doesn't pass its own on_page:. One of the ON_PAGE_MODES, or false to
# render no auto-TOC by default.
attr_writer :on_page_default
Expand All @@ -118,7 +126,7 @@ class Configuration
# "plaintext" (no highlighting, never raises).
attr_accessor :code_lexer_fallback

# Human labels for language tabs in Docs::Example, merged over the built-ins
# Human labels for language tabs in DocsUI::Example, merged over the built-ins
# (e.g. { elixir: "Elixir", curl: "cURL" }). Unknown tokens humanize.
attr_accessor :code_language_labels

Expand Down Expand Up @@ -209,10 +217,11 @@ def initialize
@title_suffix = nil
@themes = %w[dark light]
@default_theme = nil
# The sentinel default nav lambda. #nav_groups treats it as "unset" and
# derives the sidebar from #nav_registries instead; an explicit c.nav
# replaces this object so the derivation steps aside (backwards compat).
# The default nav lambda. Until a site assigns #nav (which sets
# @nav_explicit), #nav_groups treats nav as "unset" and derives the sidebar
# from #nav_registries instead; an explicit c.nav (any lambda) then wins.
@nav = DEFAULT_NAV
@nav_explicit = false
@nav_registries = {}
@version_badge = nil
@stylesheets = %w[application]
Expand Down Expand Up @@ -360,42 +369,65 @@ def default_theme
# heading whose pages are all unauthored (empty nav_items) is dropped so no
# empty group renders.
def nav_groups
return nav_groups_from_registries if @nav.equal?(DEFAULT_NAV)
return nav_groups_from_registries unless @nav_explicit

result = @nav.respond_to?(:call) ? @nav.call : @nav
result || {}
end

# The resolved version badge string, or nil.
# The rendered version badge. A callable is invoked; a plain String (or any
# non-nil value) is coerced to its string form — so `c.version_badge = "v1.2"`
# renders, not only a lambda.
def version_badge_text
return unless @version_badge.respond_to?(:call)
return if @version_badge.nil?
return @version_badge.call if @version_badge.respond_to?(:call)

@version_badge.call
@version_badge.to_s
end

# The Rouge theme class resolved from #code_theme (String or class).
# The default Rouge theme both #code_theme_class and #code_theme_dark_class
# fall back to when a configured theme name can't be resolved — so a typo'd
# theme name degrades gracefully instead of raising on every code block.
DEFAULT_CODE_THEME = "Rouge::Themes::Monokai"

# The Rouge theme class resolved from #code_theme (String or class). A String
# name that doesn't resolve degrades to the default theme rather than raising
# NameError on every DocsUI::Code render.
def code_theme_class
return @code_theme if @code_theme.is_a?(Class)

Object.const_get(@code_theme.to_s)
resolve_theme(@code_theme) || Object.const_get(DEFAULT_CODE_THEME)
end

# The dark Rouge theme class resolved from #code_theme_dark (String or
# class), or nil when unset — mirrors #code_theme_class. Docs::Code emits
# dark code CSS only when this is non-nil.
# class), or nil when unset — mirrors #code_theme_class. DocsUI::Code emits
# dark code CSS only when this is non-nil, so an unresolvable name degrades to
# nil (no dark restyle) rather than raising.
def code_theme_dark_class
return if @code_theme_dark.nil?
return @code_theme_dark if @code_theme_dark.is_a?(Class)

Object.const_get(@code_theme_dark.to_s)
resolve_theme(@code_theme_dark)
end

# The dark themes the site actually ships: #dark_themes intersected with
# #themes, in #themes declaration order. Docs::Code scopes the dark theme's
# #themes, in #themes declaration order. DocsUI::Code scopes the dark theme's
# CSS under [data-theme=X] for each of these, so a dark theme that isn't in
# the Tailwind build never emits dead CSS.
def dark_themes_shipped
Array(@themes) & Array(@dark_themes)
end

private

# Resolve a Rouge theme constant from its String name, returning nil (not
# raising) when the name doesn't resolve — a typo'd or unloaded theme must
# not crash every code block on the page.
def resolve_theme(name)
Object.const_get(name.to_s)
rescue NameError
nil
end
end
end
13 changes: 9 additions & 4 deletions lib/docs_kit/markdown_export/blocks.rb
Original file line number Diff line number Diff line change
Expand Up @@ -103,11 +103,16 @@ def blockquote(node)
quote(render(node))
end

# A callout → `> **Label:** body` as a blockquote. The label comes from the
# level; the body is the callout's inner text as inline Markdown.
# A callout → `> **Label:** body` as a blockquote. Callout stamps a
# `div.font-semibold` title (present only when title: is given) and a
# `div.text-sm` body. Read them separately: the author's title is the label
# when present (else the level label), and only the body div is rendered so
# the title never fuses into the body text.
def callout(node, level)
label = CALLOUT_LABELS.fetch(level, "Note")
body = @inline.render(node).strip
title = node.at_css(".font-semibold")
body_node = node.at_css(".text-sm") || node
label = title ? @inline.render(title).strip : CALLOUT_LABELS.fetch(level, "Note")
body = @inline.render(body_node).strip
quote("**#{label}:** #{body}")
end

Expand Down
11 changes: 10 additions & 1 deletion lib/docs_kit/markdown_export/inline.rb
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ def render_node(node)
# any other wrapper recurses so its text survives.
def element(node, name)
case name
when "code" then "`#{node.text}`"
when "code" then code_span(node.text)
when "a" then link(node)
when "img" then image(node)
when "br" then " \n"
Expand All @@ -67,6 +67,15 @@ def heading_text(node)

private

# A GFM-correct inline code span. The fence is a backtick run one longer
# than the longest run inside the text, so an interior backtick can never
# close the span; a space pads content that starts or ends with a backtick.
def code_span(text)
fence = "`" * ((text.scan(/`+/).map(&:length).max || 0) + 1)
pad = text.start_with?("`") || text.end_with?("`") ? " " : ""
"#{fence}#{pad}#{text}#{pad}#{fence}"
end

def self_anchor?(node)
node.element? && node.name == "a" && node["href"].to_s.start_with?("#")
end
Expand Down
11 changes: 9 additions & 2 deletions lib/docs_kit/markdown_export/table.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,21 @@ def render(node)
rows = rows(node)
return "" if rows.empty?

header, *body = rows
lines = [row(header), separator(header.length)]
width = rows.map(&:length).max
header, *body = pad(rows, width)
lines = [row(header), separator(width)]
lines.concat(body.map { |cells| row(cells) })
lines.join("\n")
end

private

# Pad every row out to +width+ with empty cells so the header, separator,
# and all body rows declare the same column count (a rectangular GFM table).
def pad(rows, width)
rows.map { |cells| cells + Array.new(width - cells.length, "") }
end

# All rows as arrays of cell strings, header row first. A <thead> row leads;
# <tbody>/bare <tr> rows follow.
def rows(node)
Expand Down
4 changes: 3 additions & 1 deletion lib/docs_kit/registry.rb
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,9 @@ def grouped
# so the sidebar never links a page that isn't written yet. This is the
# transform every site used to hand-write in its nav lambda.
def nav_items
all.select(&:view_class).group_by(&:group).transform_values do |items|
all.select { |item| item.respond_to?(:view_class) && item.view_class }
.group_by { |item| item.public_send(group_by_attribute) }
.transform_values do |items|
items.map { |item| DocsKit::NavItem.new(href: item.href, label: item.title, icon: item.icon) }
end
end
Expand Down
Loading
Loading