diff --git a/CHANGELOG.md b/CHANGELOG.md index 804eae7..b555af5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 @@ -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: "", viewbox:, label: }`, rendered with `fill: currentColor` diff --git a/README.md b/README.md index 6217c4b..aa56f2e 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/app/components/docs_ui/code.rb b/app/components/docs_ui/code.rb index c8b673d..af778b9 100644 --- a/app/components/docs_ui/code.rb +++ b/app/components/docs_ui/code.rb @@ -9,12 +9,16 @@ 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 @@ -22,7 +26,7 @@ class Code < Phlex::HTML 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 @@ -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. diff --git a/docs/app/views/docs/pages/authoring.rb b/docs/app/views/docs/pages/authoring.rb index 1be8beb..62db325 100644 --- a/docs/app/views/docs/pages/authoring.rb +++ b/docs/app/views/docs/pages/authoring.rb @@ -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 diff --git a/docs/app/views/docs/pages/components.rb b/docs/app/views/docs/pages/components.rb index 6d669b2..e1ddb21 100644 --- a/docs/app/views/docs/pages/components.rb +++ b/docs/app/views/docs/pages/components.rb @@ -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" ] ) diff --git a/lib/generators/docs_kit/install/templates/agents_md.erb b/lib/generators/docs_kit/install/templates/agents_md.erb index f03b346..168e4e4 100644 --- a/lib/generators/docs_kit/install/templates/agents_md.erb +++ b/lib/generators/docs_kit/install/templates/agents_md.erb @@ -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 diff --git a/lib/generators/docs_kit/page/page_generator.rb b/lib/generators/docs_kit/page/page_generator.rb index 963ad74..5c03f7e 100644 --- a/lib/generators/docs_kit/page/page_generator.rb +++ b/lib/generators/docs_kit/page/page_generator.rb @@ -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/ diff --git a/spec/docs_ui/code_spec.rb b/spec/docs_ui/code_spec.rb index 43c86a8..3c5be59 100644 --- a/spec/docs_ui/code_spec.rb +++ b/spec/docs_ui/code_spec.rb @@ -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 diff --git a/spec/generators/page_generator_spec.rb b/spec/generators/page_generator_spec.rb index 174708a..72665cc 100644 --- a/spec/generators/page_generator_spec.rb +++ b/spec/generators/page_generator_spec.rb @@ -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) }