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
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@

### Fixed

- **`docs_kit:page` injected the registry line once per group.** In a registry
laid out as blank-line-separated groups (the layout `docs_kit:install`
produces), the "page line not followed by a page line" Regexp anchor matched
the end of *every* group and Thor's `inject_into_file` replaced every match.
The anchor is now the file's last `page` line as a String, so one run adds
exactly one line, at the end of the last group.
- **SEO `og:image` 404.** The og:image tag pointed at the raw config path
(`https://site/og/og.png`), which isn't a served URL — Propshaft serves the
digested asset under `/assets`. A relative `og_image` is now resolved through
Expand All @@ -18,6 +24,12 @@

### Added

- **`DocsUI::Code` infers the language from `filename:`.**
`DocsUI::Code(<<~YAML, filename: "config/deploy.yml")` now highlights as YAML
instead of Ruby: with no `lexer:`, the lexer is guessed from Rouge's filename
globs (`*.yml` → yaml, `Dockerfile` → docker, `*.sh` → shell, …). An explicit
`lexer:` still wins, and a block with neither (or an unguessable filename)
stays Ruby, so existing output is unchanged.
- **`DocsUI::Landing` hero logo (`c.landing.logo`).** The landing hero now takes
an optional brand mark above the eyebrow, in two forms: an inline single-path
SVG (`{ svg: "<path d>", viewbox:, label: }`, rendered with `fill: currentColor`
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -433,6 +433,10 @@ DocsUI::Section("Add the gem", id: "add", description: …) # title positional
DocsUI::Code(source, lexer: :ruby, filename: "Gemfile") # source positional
```

`DocsUI::Code` infers the language from `filename:` (`config/deploy.yml` →
YAML, `Dockerfile` → docker, `bin/setup.sh` → shell); `lexer:` overrides the
guess, and with neither the block is highlighted as Ruby.

For the two wrappers that take **no** positional argument — prose and a
multi-language example — `DocsUI::Page` gives you lowercase helpers so a block
needs no parens:
Expand Down
39 changes: 29 additions & 10 deletions app/components/docs_ui/code.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,20 +9,24 @@ module DocsUI
# stylesheet asset is required.
#
# render DocsUI::Code.new(ruby_source) # ruby, no title
# render DocsUI::Code.new(py, lexer: :python, filename: "a.py") # any language
# render DocsUI::Code.new(yaml, filename: "config/deploy.yml") # yaml, inferred
# render DocsUI::Code.new(py, lexer: :python, filename: "a.py") # explicit wins
#
# Any language Rouge knows (~200 lexers) works by its name or alias — python,
# go, rust, elixir, kotlin, swift, json, dockerfile, ... — no allowlist. Add
# friendly lexer aliases via DocsKit.configure (code_lexer_aliases). An unknown
# language falls back to plaintext (never raises). (Tab labels are a
# The language resolves in order: an explicit `lexer:`; a guess from the
# `filename:` (Rouge's own filename globs — *.yml → yaml, Dockerfile → docker,
# *.sh → shell, …); else ruby. Any language Rouge knows (~200 lexers) works by
# its name or alias — python, go, rust, elixir, kotlin, swift, json,
# dockerfile, ... — no allowlist. Add 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

FORMATTER = Rouge::Formatters::HTML.new

def initialize(source, lexer: :ruby, filename: nil)
def initialize(source, lexer: nil, filename: nil)
@source = source.to_s.strip
@lexer = lexer
@filename = filename
Expand Down Expand Up @@ -66,11 +70,26 @@ def title_bar
end
end

# Resolve @lexer to a Rouge lexer instance. Order: an explicit Rouge::Lexer
# class/instance passed through; a configured friendly alias; Rouge's own
# registry (name/alias); then the configured fallback (plaintext).
# Resolve the lexer to a Rouge lexer instance. Order: an explicit Rouge::Lexer
# class/instance passed through; an explicit name via a configured friendly
# alias → Rouge's own registry → the configured fallback (plaintext); with no
# `lexer:` given, a guess from the filename; else the ruby default.
def lexer
explicit_lexer || (find_lexer(@lexer.to_s) || Rouge::Lexers::PlainText).new
explicit_lexer ||
(@lexer && (find_lexer(@lexer.to_s) || Rouge::Lexers::PlainText).new) ||
guessed_lexer ||
Rouge::Lexers::Ruby.new
end

# A lexer inferred from @filename via Rouge's declared filename globs, or nil
# when there is no filename, nothing matches, or the match is ambiguous.
# (Lexer.guesses, not Lexer.guess: the latter answers PlainText for a
# no-match, which is indistinguishable from a real guess.)
def guessed_lexer
return nil if @filename.nil?

guesses = Rouge::Lexer.guesses(filename: @filename.to_s)
guesses.size == 1 ? guesses.first.new : nil
end

# A Rouge::Lexer instance passed directly (class or instance), else nil.
Expand Down
2 changes: 1 addition & 1 deletion docs/app/views/docs/pages/authoring.rb
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,7 @@ def building_blocks_section

md <<~'MD'
The primary argument is always positional —
`Section("Title")`, `Code(source)`, `Header("Title")` — with
`Section("Title")`, `Code(source, filename:)` (the filename picks the language), `Header("Title")` — with
modifiers as keywords (`description:`, `eyebrow:`).

For the wrappers that take no argument, use the lowercase page
Expand Down
4 changes: 2 additions & 2 deletions docs/app/views/docs/pages/components.rb
Original file line number Diff line number Diff line change
Expand Up @@ -228,8 +228,8 @@ class User < ApplicationRecord
render DocsUI::PropTable.new(
[
[ "source", "String", "—", "The code to highlight." ],
[ "lexer", "Symbol", ":ruby", "Any Rouge language — :shell, :yaml, :erb, :python, :go, etc." ],
[ "filename", "String, nil", "nil", "Optional filename bar above the block." ]
[ "lexer", "Symbol", "inferred", "Any Rouge language — :shell, :yaml, :erb, :python, :go, etc. Overrides the filename guess; ruby when neither is given." ],
[ "filename", "String, nil", "nil", "Optional filename bar above the block. Also selects the language (*.yml → yaml, Dockerfile → docker, *.sh → shell)." ]
],
headers: [ "Arg", "Type", "Default", "Description" ]
)
Expand Down
3 changes: 3 additions & 0 deletions lib/generators/docs_kit/install/templates/agents_md.erb
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,9 @@ end
- **The primary argument is positional; modifiers are keywords.**
`Section("Title", description:)`, `Code(source, filename:)`,
`Header("Title", eyebrow:)`.
- **`Code`'s `filename:` selects the language** (`*.yml` → yaml, `Dockerfile`
→ docker, `*.sh` → shell, …); pass `lexer:` only to override the guess or when
there is no filename (the default is ruby).
- **Wrappers that take no positional arg use lowercase page helpers** so a block
needs no parens: `md <<~'MD' … MD`, `prose { … }`, `example { |ex| … }`,
`operation "operationId"`. (A bare `DocsUI::Prose do` is a Ruby SyntaxError; the
Expand Down
14 changes: 10 additions & 4 deletions lib/generators/docs_kit/page/page_generator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -101,13 +101,19 @@ def legacy_entries?(source)
source.match?(/^\s*entries\s*\[/) && !source.match?(/^\s*page\s+["']/)
end

# Inject after the last existing `page` line so ordering lands at the end
# of the group; else after view_namespace/path_prefix; else after the
# `extend DocsKit::Registry` line.
# Inject after the last existing `page` line of the FILE so ordering lands
# at the end of the last group; else after view_namespace/path_prefix;
# else after the `extend DocsKit::Registry` line.
#
# The `page` anchor is the last matching line as a String, not a Regexp:
# Thor's inject_into_file replaces EVERY match of a Regexp `after:`, so a
# "page line not followed by a page line" pattern fires once per group in
# a registry laid out with blank-line-separated groups (the layout
# docs_kit:install produces) and duplicates the entry.
def registry_anchor(source)
case source
when /^\s*page\s+["']/
/^\s*page .*\n(?!\s*page )/
source.lines.grep(/^\s*page\s+["']/).last
when /^\s*view_namespace\s/
/^\s*view_namespace .*\n/
when /^\s*path_prefix\s/
Expand Down
35 changes: 35 additions & 0 deletions spec/docs_ui/code_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,41 @@ def csp_nonce = "testnonce"
end
end

# `filename:` selects the language when `lexer:` is not given — the natural
# `DocsUI::Code(<<~YAML, filename: "config/deploy.yml")` highlights as YAML
# instead of silently lexing as Ruby.
describe "lexer inference from filename:" do
it "guesses the lexer from the filename extension" do
html = described_class.new("foo: bar\nbaz: [1, 2]", filename: "config/deploy.yml").call

expect(html).to include('data-md-lang="yaml"')
expect(html).not_to include('class="err"')
end

it "guesses from a well-known basename (Dockerfile)" do
resolved = described_class.new("FROM ruby", filename: "Dockerfile").send(:lexer)
expect(resolved).to be_a(Rouge::Lexers::Docker)
end

it "lets an explicit lexer: win over the filename" do
html = described_class.new("puts 'hi'", filename: "x.yml", lexer: :ruby).call

expect(html).to include('data-md-lang="ruby"')
end

it "falls back to ruby for an unguessable filename" do
html = described_class.new("puts 'hi'", filename: "notes").call

expect(html).to include('data-md-lang="ruby"')
end

it "still defaults to ruby with no filename and no lexer" do
html = described_class.new("puts 'hi'").call

expect(html).to include('data-md-lang="ruby"')
end
end

it "falls back to plaintext for an unknown lexer" do
html = described_class.new("anything", lexer: :nope).call

Expand Down
43 changes: 43 additions & 0 deletions spec/generators/page_generator_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,49 @@ def silence_stream
end
end

describe "a grouped registry (blank lines + comments between page groups)" do
# The layout docs_kit:install itself produces: groups of `page` lines
# separated by a blank line and a `# comment`. The anchor must be the LAST
# page line of the FILE, not the last line of every group.
let(:grouped) do
<<~RUBY
# frozen_string_literal: true

class Doc
extend DocsKit::Registry
path_prefix "/docs"
view_namespace "Views::Docs::Pages"

# Guide
page "Installation", group: "Guide"
page "Configuration", group: "Guide"

# Deploying
page "Docker", group: "Deploying"
page "Tunnel", group: "Deploying"
end
RUBY
end

before { seed_registry(grouped) }

it "adds exactly one line, after the last page line of the file" do
run_generator(["New Page"], { "group" => "Deploying" })

doc = read("app/models/doc.rb")
expect(doc.scan(%(page "New Page")).size).to eq(1)
expect(doc.index(%(page "Tunnel"))).to be < doc.index(%(page "New Page"))
expect(doc.scan(/^\s*page /).size).to eq(5)
end

it "is idempotent on a second run" do
run_generator(["New Page"], { "group" => "Deploying" })
run_generator(["New Page"], { "group" => "Deploying", "skip" => true })

expect(read("app/models/doc.rb").scan(%(page "New Page")).size).to eq(1)
end
end

describe "flag overrides" do
before { seed_registry(registry_v2) }

Expand Down
Loading