Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Render documentation #35

Draft
wants to merge 3 commits into
base: master
Choose a base branch
from
Draft
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
581 changes: 5 additions & 576 deletions README.md

Large diffs are not rendered by default.

12 changes: 12 additions & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# texd documentation

1. [Operation Modes](./operation-modes.md)
2. [CLI Options](./cli-options.md)
3. HTTP API
1. [Render a document](./http-api/render.md)
2. [Status and Configuration](./http-api/status.md)
3. [Metrics](./http-api/metrics.md)
4. [Simple Web UI](./http-api/web-ui.md)
4. [Reference Store](./reference-store.md)
5. [History](./history.md)
6. [Future](./future.md)
60 changes: 60 additions & 0 deletions docs/cli-options.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
# CLI Options

Calling texd with options works in any [operation mode](./operation-modes.md);
these commands are equivalent:

```console
$ texd -h
$ texd texlive/texlive:latest -h
$ docker run --rm -t digineode/texd:latest -h
```

- `--help`, `-h`

Prints a short option listing and exits.

- `--version`, `-v`

Prints version information and exits.

- `--listen-address=ADDR`, `-b ADDR` (Default: `:2201`)

Specifies host address (optional) and port number for the HTTP API to bind to. Valid values are,
among others:

- `:2201` (bind to all addresses on port 2201)
- `localhost:2201` (bind only to localhost on port 2201)
- `[fe80::dead:c0ff:fe42:beef%eth0]:2201` (bind to a link-local IPv6 address on a specific
interface)

- `--tex-engine=ENGINE`, `-X ENGINE` (Default: `xelatex`)

TeX engine used to compile documents. Can be overridden on a per-request basis (see HTTP API
below). Supported engines are `xelatex`, `lualatex`, and `pdflatex`.

- `--compile-timeout=DURATION`, `-t DURATION` (Default: `1m`)

Maximum duration for a document rendering process before it is killed by texd. The value must be
acceptable by Go's `ParseDuruation` function.

- `--parallel-jobs=NUM`, `-P NUM` (Default: number of cores)

Concurrency level. PDF rendering is inherently single threaded, so limiting the document
processing to the number of cores is a good start.

- `--queue-wait=DURATION`, `-w DURATION` (Default: `10s`)

Time to wait in queue before aborting. When <= 0, clients will immediately receive a "full queue"
response.

- `--job-directory=PATH`, `-D PATH` (Default: OS temp directory)

Place to put job sub directories in. The path must exist and it must be writable.

- `--pull` (Default: omitted)

Always pulls Docker images. By default, images are only pulled when they don't exist locally.

This has no effect when no image tags are given to the command line.

> Note: This option listing might be outdated. Run `texd --help` to get the up-to-date listing.
215 changes: 215 additions & 0 deletions docs/docs.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
package docs

import (
"bytes"
"embed"
"fmt"
"html/template"
"io"
"log"
"net/http"
"strings"

toc "github.com/abhinav/goldmark-toc"
chromahtml "github.com/alecthomas/chroma/formatters/html"
"github.com/digineo/texd"
"github.com/microcosm-cc/bluemonday"
"github.com/yuin/goldmark"
highlighting "github.com/yuin/goldmark-highlighting"
"github.com/yuin/goldmark/ast"
"github.com/yuin/goldmark/extension"
"github.com/yuin/goldmark/parser"
"github.com/yuin/goldmark/renderer/html"
"github.com/yuin/goldmark/text"
"github.com/yuin/goldmark/util"
"gopkg.in/yaml.v3"
)

//go:embed *.md **/*.md
var sources embed.FS

//go:embed docs.yml
var config []byte

//go:embed docs.html
var rawLayout string
var tplLayout = template.Must(template.New("layout").Parse(rawLayout))

type page struct {
Title string
Breadcrumbs []string
TOC *toc.TOC
CSS []byte
Body []byte
File string
Route string
Children []*page
}

type pageRoutes map[string]*page

func getRoutes(urlPrefix string) (pageRoutes, error) {
var menu page
dec := yaml.NewDecoder(bytes.NewReader(config))
dec.KnownFields(true)
if err := dec.Decode(&menu); err != nil {
return nil, err
}

urlPrefix = strings.TrimSuffix(urlPrefix, "/")
return menu.init(urlPrefix, make(pageRoutes))
}

func (pg *page) init(urlPrefix string, r pageRoutes, crumbs ...string) (pageRoutes, error) {
if pg.File != "" {
if r := strings.TrimSuffix(pg.File, ".md"); r == "README" {
pg.Route = urlPrefix
} else {
pg.Route = urlPrefix + "/" + r
}
r[pg.Route] = pg
err := pg.parseFile(urlPrefix)
if err != nil {
return nil, err
}
}
if pg.Title != "" {
pg.Breadcrumbs = append([]string{pg.Title}, crumbs...)
}
for _, child := range pg.Children {
_, err := child.init(urlPrefix, r, pg.Breadcrumbs...)
if err != nil {
return nil, err
}
}
return r, nil
}

type localLinkTransformer struct {
prefix string
}

var _ parser.ASTTransformer = (*localLinkTransformer)(nil)

func (link *localLinkTransformer) Transform(node *ast.Document, reader text.Reader, pc parser.Context) {
_ = ast.Walk(node, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
if entering && n.Kind() == ast.KindLink {
if l, ok := n.(*ast.Link); ok {
link.rewrite(l)
}
}
return ast.WalkContinue, nil
})
}

const (
localLinkPrefix = "./"
localLinkSuffix = ".md"
)

func (link *localLinkTransformer) rewrite(l *ast.Link) {
dst := string(l.Destination)
if strings.HasPrefix(dst, localLinkPrefix) && strings.HasSuffix(dst, localLinkSuffix) {
dst = strings.TrimPrefix(dst, localLinkPrefix)
dst = strings.TrimSuffix(dst, localLinkSuffix)
l.Destination = []byte(link.prefix + "/" + dst)
}
}

var sanitize = func() func(io.Reader) *bytes.Buffer {
p := bluemonday.UGCPolicy()
p.AllowAttrs("class").Globally()
return p.SanitizeReader
}()

func (pg *page) parseFile(urlPrefix string) error {
raw, err := sources.ReadFile(pg.File)
if err != nil {
return err
}

var css, body bytes.Buffer
md := goldmark.New(
goldmark.WithParserOptions(
parser.WithAutoHeadingID(),
parser.WithASTTransformers(util.PrioritizedValue{
Value: &localLinkTransformer{urlPrefix},
Priority: 999,
}),
),
goldmark.WithRendererOptions(
html.WithUnsafe(),
),
goldmark.WithExtensions(
extension.GFM,
highlighting.NewHighlighting(
highlighting.WithCSSWriter(&css),
highlighting.WithStyle("github"),
highlighting.WithFormatOptions(
chromahtml.WithLineNumbers(true),
chromahtml.WithClasses(true),
),
),
),
)

doc := md.Parser().Parse(text.NewReader(raw))
tree, err := toc.Inspect(doc, raw)
if err != nil {
return err
}
if pg.Title == "" {
if len(tree.Items) > 0 {
pg.Title = string(tree.Items[0].Title)
}
}
if err := md.Renderer().Render(&body, raw, doc); err != nil {
return err
}
pg.TOC = tree
pg.CSS = css.Bytes()
pg.Body = sanitize(&body).Bytes()
return nil
}

func Handler(prefix string) (http.Handler, error) {
type pageVars struct {
Version string
Title string
CSS template.CSS
Content template.HTML
}

routes, err := getRoutes(prefix)
if err != nil {
return nil, fmt.Errorf("failed to build docs: %w", err)
}

return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
pg := routes[r.URL.Path]
if pg == nil {
http.NotFound(w, r)
return
}

var buf bytes.Buffer
err := tplLayout.Execute(&buf, &pageVars{
Version: texd.Version(),
Title: strings.Join(pg.Breadcrumbs, " · "),
CSS: template.CSS(pg.CSS),
Content: template.HTML(pg.Body),
})

if err != nil {
log.Println(err)
code := http.StatusInternalServerError
http.Error(w, http.StatusText(code), code)
return
}

w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.WriteHeader(http.StatusOK)
_, _ = buf.WriteTo(w)
}), nil
}
39 changes: 39 additions & 0 deletions docs/docs.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ .Title }}</title>

<link rel="stylesheet" href="/assets/bootstrap-5.1.3.min.css">
<style>{{ .CSS }}</style>
</head>

<body>
<div id="app" class="pb-5">
<nav class="navbar navbar-light navbar-expand-sm bg-light">
<div class="container-fluid">
<a href="https://github.com/digineo/texd" class="navbar-brand">texd</a>

<ul class="navbar-nav me-auto">
<li class="nav-item">
<a class="nav-link" href="/">Play</a>
</li>
<li class="nav-item">
<a class="nav-link active" href="/docs">Documentation</a>
</li>
</ul>

<span class="navbar-text">
{{ .Version }}
</span>
</div>
</nav>

<div class="container">
{{ .Content }}
</div>
</div>
</body>
</html>
14 changes: 14 additions & 0 deletions docs/docs.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
file: README.md
children:
- file: operation-modes.md
- file: cli-options.md
- title: HTTP API
children:
- file: http-api/render.md
- file: http-api/status.md
- file: http-api/metrics.md
- file: http-api/web-ui.md
- file: reference-store.md
- file: history.md
- file: future.md
15 changes: 15 additions & 0 deletions docs/future.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# Future

One wishlist's item is asynchronous rendering: Consider rendering monthly invoices on the first
of each month; depending on the amount of customers/contracts/invoice positions, this can easily
mean you need to render a few thousand PDF documents.

Usually, the PDF generation is not time critical, i.e. they should finish in a reasonable amount of
time (say, within the next 6h to ensure timely delivery to the customer via email). For this to
work, the client could provide a callback URL to which texd sends the PDF via HTTP POST when
the rendering is finished.

Of course, this will also increase complexity on both sides: The client must be network-reachable
itself, an keep track of rendering request in order to associate the PDF to the correct invoice;
texd on the other hand would need a priority queue (processing async documents only if no sync
documents are enqueued), and it would need to store the callback URL somewhere.
16 changes: 16 additions & 0 deletions docs/history.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# History

texd came to life because I've build dozens of Rails applications, which all needed to build PDF
documents in one form or another (from recipies, to invoices, order confirmations, reports and
technical documentation). Each server basically needed a local TeX installation (weighing in at
several 100 MB, up to several GB). Compiling many LaTeX documents also became a bottleneck for
applications running on otherwise modest hardware (or cloud VMs), as this process is also
computationally expensive.

Over time I've considered using alternatives for PDF generation (Prawn, HexaPDF, gofpdf, SILE, iText
PDF, to name but a few), and found that the quality of the rendered PDF is far inferior to the ones
generated by LaTeX. Other times, the licensing costs are astronomical, or the library doesn't
support some layouting feature, or the library in an early alpha stage or already abandoned...

I'll admit that writing TeX templates for commercial settings is a special kind of pain-inducing
form of art. But looking back at using LaTeX for now over a decade, I still feel it's worth it.
Loading