fix(11.1-07): check go fences, README src= and shell fences from the AST
- goLang follows the highlighter's chroma lookup, so golang and main.go need src= - a src= fence in a module README is refused and never captioned - shell fences inside callouts and lists are command-checked - a fence with four or more leading spaces is an indented code block
This commit is contained in:
@@ -146,7 +146,7 @@ npm --prefix admin run gen:api # regenerate TypeScript types from admin/open
|
|||||||
|
|
||||||
`summer dev` watches an application's sources and rebuilds its binary on change.
|
`summer dev` watches an application's sources and rebuilds its binary on change.
|
||||||
|
|
||||||
`summer docs:build` renders `docs/` and every module README into a static site under `site/` (use `--out` for another directory, `--check` to validate without writing). Code blocks with a `src=` reference are copies of real source; after changing that source, run `summer docs:sync` to refresh the copies.
|
`summer docs:build` renders `docs/` and every module README into a static site under `site/` (use `--out` for another directory, `--check` to validate without writing). Code blocks with a `src=` reference are copies of real source, and `src=` works on top-level code blocks of `docs/` pages only; after changing that source, run `summer docs:sync` to refresh the copies.
|
||||||
|
|
||||||
`summer docs:serve` builds the same site into a temporary directory and previews it at `http://127.0.0.1:8088` (`--addr` to change it), rebuilding when the docs, a module or a `src=` source changes; a failed rebuild prints its problems and keeps serving the last good build. It listens only on a loopback address unless you pass `--allow-remote`. Search needs this server: browsers block the search index over `file://`.
|
`summer docs:serve` builds the same site into a temporary directory and previews it at `http://127.0.0.1:8088` (`--addr` to change it), rebuilding when the docs, a module or a `src=` source changes; a failed rebuild prints its problems and keeps serving the last good build. It listens only on a loopback address unless you pass `--allow-remote`. Search needs this server: browsers block the search index over `file://`.
|
||||||
|
|
||||||
|
|||||||
@@ -42,6 +42,6 @@ summer docs:sync
|
|||||||
summer docs:serve
|
summer docs:serve
|
||||||
```
|
```
|
||||||
|
|
||||||
`docs:build` fails, and writes nothing, when a page names an identifier that does not exist, links to a missing page or anchor, shows a command that neither `summer` nor an application binary has, or has a `src=` code block that differs from its source. After you change code that a page shows, run `summer docs:sync` to refresh the copies.
|
`docs:build` fails, and writes nothing, when a page names an identifier that does not exist, links to a missing page or anchor, shows a command that neither `summer` nor an application binary has, or has a `src=` code block that differs from its source. It also fails on a Go code block (including `golang`) without a `src=` reference, a `src=` code block inside a callout, blockquote or list item (`src=` blocks must be top-level), and a `src=` code block in a module README. After you change code that a page shows, run `summer docs:sync` to refresh the copies.
|
||||||
|
|
||||||
`docs:serve` listens only on a loopback address unless you pass `--allow-remote`. Use it to preview search, which browsers block when you open the built files directly from disk.
|
`docs:serve` listens only on a loopback address unless you pass `--allow-remote`. Use it to preview search, which browsers block when you open the built files directly from disk.
|
||||||
|
|||||||
@@ -86,18 +86,16 @@ func (s *site) checkCommands(docs []parsedDoc) ([]Problem, error) {
|
|||||||
for _, span := range codeSpans(d.doc, d.body, d.line) {
|
for _, span := range codeSpans(d.doc, d.body, d.line) {
|
||||||
check(d, span.line, span.text)
|
check(d, span.line, span.text)
|
||||||
}
|
}
|
||||||
lines := strings.Split(string(d.body), "\n")
|
fences, err := collectFences(d.doc, d.body)
|
||||||
for _, f := range scanFences(lines) {
|
if err != nil {
|
||||||
lang, _, _ := strings.Cut(f.info, " ")
|
return nil, err
|
||||||
if !slices.Contains(shellLangs, lang) {
|
}
|
||||||
|
for _, f := range fences {
|
||||||
|
if !slices.Contains(shellLangs, f.lang) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
end := f.close
|
for _, line := range f.code {
|
||||||
if end < 0 {
|
check(d, d.line+line.line, line.text)
|
||||||
end = len(lines)
|
|
||||||
}
|
|
||||||
for i := f.open + 1; i < end; i++ {
|
|
||||||
check(d, d.line+i, lines[i])
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package docsite
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"regexp"
|
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -12,52 +11,63 @@ import (
|
|||||||
// calloutTypes are the only `> [!TYPE]` callouts the theme renders.
|
// calloutTypes are the only `> [!TYPE]` callouts the theme renders.
|
||||||
var calloutTypes = []string{"NOTE", "TIP", "WARNING"}
|
var calloutTypes = []string{"NOTE", "TIP", "WARNING"}
|
||||||
|
|
||||||
// calloutMarker matches the first line of a callout blockquote.
|
|
||||||
var calloutMarker = regexp.MustCompile(`^\s{0,3}>\s?\[!([A-Za-z]+)\]\s*$`)
|
|
||||||
|
|
||||||
// checkPolicy enforces the page content rules:
|
// checkPolicy enforces the page content rules:
|
||||||
// - every go fence in a docs/ page carries src= (module READMEs are
|
// - every Go-lexer fence in a docs/ page carries src=, at any depth
|
||||||
// rendered as written, D-18);
|
// (module READMEs are rendered as written, D-18);
|
||||||
// - callouts are NOTE, TIP or WARNING;
|
// - callouts are NOTE, TIP or WARNING;
|
||||||
// - docs/ headings are plain ASCII text without links or code spans.
|
// - docs/ headings are plain ASCII text without links or code spans.
|
||||||
func (s *site) checkPolicy(docs []parsedDoc) []Problem {
|
func (s *site) checkPolicy(docs []parsedDoc) ([]Problem, error) {
|
||||||
var problems []Problem
|
var problems []Problem
|
||||||
for _, d := range docs {
|
for _, d := range docs {
|
||||||
if d.page == nil {
|
if d.page == nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
guide := d.page.Module == ""
|
guide := d.page.Module == ""
|
||||||
lines := strings.Split(string(d.body), "\n")
|
if guide {
|
||||||
fences := scanFences(lines)
|
fences, err := collectFences(d.doc, d.body)
|
||||||
inFence := make([]bool, len(lines))
|
if err != nil {
|
||||||
for _, f := range fences {
|
return nil, err
|
||||||
end := f.close
|
|
||||||
if end < 0 {
|
|
||||||
end = len(lines) - 1
|
|
||||||
}
|
}
|
||||||
for i := f.open; i <= end; i++ {
|
for _, f := range fences {
|
||||||
inFence[i] = true
|
if _, hasSrc := ParseSrc(f.info); goLang(f.lang) && !hasSrc {
|
||||||
}
|
problems = append(problems, Problem{File: d.file, Line: d.line + f.line, Rule: "snippet",
|
||||||
fields := strings.Fields(f.info)
|
Message: f.lang + " code block has no src= reference"})
|
||||||
if guide && len(fields) > 0 && fields[0] == "go" &&
|
}
|
||||||
!slices.ContainsFunc(fields[1:], func(f string) bool { return strings.HasPrefix(f, "src=") }) {
|
|
||||||
problems = append(problems, Problem{File: d.file, Line: d.line + f.open, Rule: "snippet",
|
|
||||||
Message: "go code block has no src= reference"})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for i, line := range lines {
|
|
||||||
if inFence[i] {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if m := calloutMarker.FindStringSubmatch(line); m != nil && !slices.Contains(calloutTypes, m[1]) {
|
|
||||||
problems = append(problems, Problem{File: d.file, Line: d.line + i, Rule: "callout",
|
|
||||||
Message: fmt.Sprintf("unknown type %s (use NOTE, TIP or WARNING)", m[1])})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
problems = append(problems, calloutProblems(d)...)
|
||||||
if guide {
|
if guide {
|
||||||
problems = append(problems, headingProblems(d)...)
|
problems = append(problems, headingProblems(d)...)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return problems, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// calloutProblems reports blockquotes whose marker is not NOTE, TIP or
|
||||||
|
// WARNING. calloutTransformer has already turned the known types into
|
||||||
|
// callout nodes, so a remaining blockquote with a marker is unknown.
|
||||||
|
// The marker is read from the AST, so a copy of it inside a code fence
|
||||||
|
// is not a callout.
|
||||||
|
func calloutProblems(d parsedDoc) []Problem {
|
||||||
|
var problems []Problem
|
||||||
|
_ = gast.Walk(d.doc, func(n gast.Node, entering bool) (gast.WalkStatus, error) {
|
||||||
|
bq, ok := n.(*gast.Blockquote)
|
||||||
|
if !entering || !ok {
|
||||||
|
return gast.WalkContinue, nil
|
||||||
|
}
|
||||||
|
para, ok := bq.FirstChild().(*gast.Paragraph)
|
||||||
|
if !ok || para.Lines().Len() == 0 {
|
||||||
|
return gast.WalkContinue, nil
|
||||||
|
}
|
||||||
|
first := para.Lines().At(0)
|
||||||
|
m := calloutLine.FindStringSubmatch(strings.TrimSpace(string(first.Value(d.body))))
|
||||||
|
if m == nil || slices.Contains(calloutTypes, m[1]) {
|
||||||
|
return gast.WalkContinue, nil
|
||||||
|
}
|
||||||
|
problems = append(problems, Problem{File: d.file, Line: lineOf(d.body, first.Start, d.line), Rule: "callout",
|
||||||
|
Message: fmt.Sprintf("unknown type %s (use NOTE, TIP or WARNING)", m[1])})
|
||||||
|
return gast.WalkContinue, nil
|
||||||
|
})
|
||||||
return problems
|
return problems
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -262,7 +262,11 @@ func (s *site) checkContent() ([]Problem, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
problems = append(problems, fp...)
|
problems = append(problems, fp...)
|
||||||
problems = append(problems, s.checkPolicy(docs)...)
|
pp, err := s.checkPolicy(docs)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
problems = append(problems, pp...)
|
||||||
return problems, nil
|
return problems, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -84,6 +84,15 @@ func highlight(lang, code string) string {
|
|||||||
return b.String()
|
return b.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// goLang reports whether lang is the Go lexer the highlighter uses.
|
||||||
|
// chroma's registry resolves names, aliases, case and file names, so
|
||||||
|
// go, Go, GO, golang, Golang and main.go are Go. go-html-template,
|
||||||
|
// go-text-template, text and an empty language are not.
|
||||||
|
func goLang(lang string) bool {
|
||||||
|
lexer := lexerFor(lang)
|
||||||
|
return lexer != nil && lexer.Config().Name == "Go"
|
||||||
|
}
|
||||||
|
|
||||||
// lexerFor returns the chroma lexer for a fence language, or nil for
|
// lexerFor returns the chroma lexer for a fence language, or nil for
|
||||||
// plain text and unknown languages.
|
// plain text and unknown languages.
|
||||||
func lexerFor(lang string) chroma.Lexer {
|
func lexerFor(lang string) chroma.Lexer {
|
||||||
|
|||||||
@@ -469,7 +469,9 @@ func scanFences(lines []string) []fence {
|
|||||||
|
|
||||||
func openFence(line string) (fence, bool) {
|
func openFence(line string) (fence, bool) {
|
||||||
trimmed := strings.TrimLeft(line, " ")
|
trimmed := strings.TrimLeft(line, " ")
|
||||||
if len(trimmed) < 3 || (trimmed[0] != '`' && trimmed[0] != '~') {
|
// CommonMark: a fence has at most three leading spaces. Four or more
|
||||||
|
// is an indented code block, which is what goldmark parses.
|
||||||
|
if len(line)-len(trimmed) > 3 || len(trimmed) < 3 || (trimmed[0] != '`' && trimmed[0] != '~') {
|
||||||
return fence{}, false
|
return fence{}, false
|
||||||
}
|
}
|
||||||
c := trimmed[0]
|
c := trimmed[0]
|
||||||
|
|||||||
@@ -452,6 +452,11 @@ type drift struct {
|
|||||||
// its container marker, so the fence is refused instead of extracted.
|
// its container marker, so the fence is refused instead of extracted.
|
||||||
const nestedSrcMessage = "src= code block must be a top-level block of the page, not inside a callout, blockquote or list item"
|
const nestedSrcMessage = "src= code block must be a top-level block of the page, not inside a callout, blockquote or list item"
|
||||||
|
|
||||||
|
// readmeSrcMessage is the problem for a src= fence in a module README.
|
||||||
|
// Those fences are rendered as written and never compared, so a src=
|
||||||
|
// there would claim a check that does not run.
|
||||||
|
const readmeSrcMessage = "module README code blocks are rendered as written; src= is verified only in docs/ pages"
|
||||||
|
|
||||||
// checkFences verifies every src= fence in fences. lines are the body
|
// checkFences verifies every src= fence in fences. lines are the body
|
||||||
// lines the fences index into. lineBase is the 1-based file line of
|
// lines the fences index into. lineBase is the 1-based file line of
|
||||||
// lines[0]; file is the display path for problems.
|
// lines[0]; file is the display path for problems.
|
||||||
@@ -513,19 +518,30 @@ func unindent(l string, n int) string {
|
|||||||
return l[i:]
|
return l[i:]
|
||||||
}
|
}
|
||||||
|
|
||||||
// checkSnippets verifies the src= fences of every docs page. Module
|
// checkSnippets verifies the src= fences of every page. A docs/ page is
|
||||||
// READMEs are skipped here; a later check refuses src= on those pages
|
// compared with its source. A module README is rendered as written, so
|
||||||
// because their fences are rendered as written.
|
// any src= fence there is refused instead of checked.
|
||||||
func (s *site) checkSnippets(docs []parsedDoc) ([]Problem, error) {
|
func (s *site) checkSnippets(docs []parsedDoc) ([]Problem, error) {
|
||||||
var problems []Problem
|
var problems []Problem
|
||||||
for _, d := range docs {
|
for _, d := range docs {
|
||||||
if d.page == nil || d.page.Module != "" {
|
if d.page == nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
fences, err := collectFences(d.doc, d.body)
|
fences, err := collectFences(d.doc, d.body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if d.page.Module != "" {
|
||||||
|
for _, f := range fences {
|
||||||
|
ref, ok := ParseSrc(f.info)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
problems = append(problems, Problem{File: d.file, Line: d.line + f.line, Rule: "snippet",
|
||||||
|
Message: ref.String() + ": " + readmeSrcMessage})
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
_, ps, err := checkFences(s.opts.Root, d.file, strings.Split(string(d.body), "\n"), d.line, fences)
|
_, ps, err := checkFences(s.opts.Root, d.file, strings.Split(string(d.body), "\n"), d.line, fences)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
Reference in New Issue
Block a user