Generics in Go 1.18

January 17, 2022

I spent some time trying the generics types and generic functions in Go 1.18, and I like what they’ve got so far.

Below is a Go 1.17 program which prints the results of the following functions:

  • SumInts(): calculate the the sum of of a slice of int values.
  • SumFloats(): calculate the the sum of of a slice of float32 values.
  • SumComplexes(): calculate the the sum of of a slice of complex64 values.
package main

import "fmt"

// Return sum of all values in slice of ints.
func SumInts(m []int) int {
  var r int

  for _, v := range(m) {
    r += v
  }

  return r
}

// Return sum of all values in slice of float32s.
func SumFloats(m []float32) float32 {
  var r float32

  for _, v := range(m) {
    r += v
  }

  return r
}

// Return sum of all values in slice of complex64s.
func SumComplexes(m []complex64) complex64 {
  var r complex64

  for _, v := range(m) {
    r += v
  }

  return r
}

var (
  // test integers
  ints = []int { 10, 20, 30 }

  // test floating point numbers
  floats = []float32 { 10.0, 20.0, 30.0 }

  // test complex numbers
  complexes = []complex64 { complex(10, 1), complex(20, 2), complex(30, 3) }
)

func main() {
  // print sums
  fmt.Printf("ints = %d\n", SumInts(ints))
  fmt.Printf("floats = %2.1f\n", SumFloats(floats))
  fmt.Printf("complexes = %g\n", SumComplexes(complexes))
}

 

Here’s the same program, written using Go 1.18 generics:

package main

import "fmt"

// Return sum of all numeric values in slice.
func Sum[V ~int|~float32|~complex64](vals []V) V {
  var r V

  for _, v := range(vals) {
    r += v
  }

  return r
}

var (
  // test integers
  ints = []int { 10, 20, 30 }

  // test floating point numbers
  floats = []float32 { 10.0, 20.0, 30.0 }

  // test complex numbers
  complexes = []complex64 { complex(10, 1), complex(20, 2), complex(30, 3) }
)

func main() {
  // print sums using generics w/explicit types
  fmt.Printf("ints = %d\n", Sum[int](ints))
  fmt.Printf("floats = %2.1f\n", Sum[float32](floats))
  fmt.Printf("complexes = %g\n", Sum[complex64](complexes))
}

 

You can use type inference to drop the type parameters in many instances. For example, we can rewrite main() from the previous example like this:

func main() {
  // print sums using generics w/explicit types
  fmt.Printf("ints = %d\n", Sum(ints))
  fmt.Printf("floats = %2.1f\n", Sum(floats))
  fmt.Printf("complexes = %g\n", Sum(complexes))
}

 

Generics can also be used in type definitions. Example:

package main

import "fmt"

// Fraction
type Frac[T ~int|~int32|~int64] struct {
  num T // numerator
  den T // denominator
}

// Add two fractions.
func (a Frac[T]) Add(b Frac[T]) Frac[T] {
  return Frac[T] { a.num + b.num, a.den * b.den }
}

// Multiple fractions.
func (a Frac[T]) Mul(b Frac[T]) Frac[T] {
  return Frac[T] { a.num * b.num, a.den * b.den }
}

// Return inverse of fraction.
func (a Frac[T]) Inverse() Frac[T] {
  return Frac[T] { a.den, a.num }
}

// Return string representation of fraction.
func (a Frac[T]) String() string {
  return fmt.Sprintf("%d/%d", a.num, a.den)
}

func main() {
  // test fractions
  fracs = []Frac[int] {
    Frac[int] { 1, 2 },
    Frac[int] { 3, 4 },
    Frac[int] { 5, 6 },
  }

  // print fractions
  for _, f := range(fracs) {
    fmt.Printf("%s => %s\n", f, f.Mul(f.Add(f.Inverse())))
  }
}

 

Interface type declarations can now be used to define the constraints that a matching type must satisfy. In addition to the ability to specify methods that a matching type must implement, a type constraint specified as an interface may also specify a union of terms indicating the set of matching types.

Type union terms can be tilde-prefixed (example: ~int), which indicates that the underlying type must match the given type.

For example, the Frac type declaration from the previous example could be written like this instead:

// Integral number type.
type integral interface {
  ~int | ~int32 | ~int64
}

// Fraction
type Frac[T integral] struct {
  num T // numerator
  den T // denominator
}

 

There are two new predeclared identifiers:

  • any: An alias for interface {}.
  • comparable: Any type which can be compared for equality with == and !=. Useful for the parameterizing map keys.

There is a new constraints package, which (not yet visible in the online Go documentation as of this writing) that provides a couple of useful unions, but it’s relatively anemic at the moment:

$ go1.18beta1 doc -all constraints
package constraints // import "constraints"

Package constraints defines a set of useful constraints to be used with type
parameters.

TYPES

type Complex interface {
	~complex64 | ~complex128
}
    Complex is a constraint that permits any complex numeric type. If future
    releases of Go add new predeclared complex numeric types, this constraint
    will be modified to include them.

type Float interface {
	~float32 | ~float64
}
    Float is a constraint that permits any floating-point type. If future
    releases of Go add new predeclared floating-point types, this constraint
    will be modified to include them.

type Integer interface {
	Signed | Unsigned
}
    Integer is a constraint that permits any integer type. If future releases of
    Go add new predeclared integer types, this constraint will be modified to
    include them.

type Ordered interface {
	Integer | Float | ~string
}
    Ordered is a constraint that permits any ordered type: any type that
    supports the operators < <= >= >. If future releases of Go add new ordered
    types, this constraint will be modified to include them.

type Signed interface {
	~int | ~int8 | ~int16 | ~int32 | ~int64
}
    Signed is a constraint that permits any signed integer type. If future
    releases of Go add new predeclared signed integer types, this constraint
    will be modified to include them.

type Unsigned interface {
	~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 | ~uintptr
}
    Unsigned is a constraint that permits any unsigned integer type. If future
    releases of Go add new predeclared unsigned integer types, this constraint
    will be modified to include them.

 

Using the constraints package, the Frac type from the previous example could be written like this:

import "constraints"

// Fraction
type Frac[T constraints.Signed] struct {
  num T // numerator
  den T // denominator
}

 

And with constraints, the Sum() function from the first example could be defined like this:

// Numeric value.
type Number interface {
  constraints.Integer | constraints.Float | constraints.Complex
}

// Return sum of all numeric values in slice.
func Sum[V Number](vals []V) V {
  var r V

  for _, v := range(vals) {
    r += v
  }

  return r
}

 

Other useful tidbits:

Update (2021-01-19): Minor wording changes, add information about tilde prefixes in type constraints.

Tiny Binaries: Assembly Optimization

January 1, 2022

Here’s how I reduced the assembly binary size in Tiny Binaries from 456 bytes to 114 bytes.

Shrinking the Code

Below is the original assembly code (asm-naive in the results):

;
; hi.s: unoptimized linux x86-64 assembly implementation.
;

bits 64

global _start

section .rodata
  ; "hi!\n"
  hi  db  "hi!", 10
  len equ $ - hi

section .text

_start:
  mov rax, 1 ; write
  mov rdi, 1 ; fd
  mov rsi, hi ; msg
  mov rdx, len ; len
  syscall ; call write()

  mov rax, 60 ; exit
  mov rdi, 0 ; exit code
  syscall ; call exit()

 

This produces a 456 byte binary with 39 bytes of code and 4 bytes of data:

$ make
nasm -f elf64 -o hi.o hi.s
ld -s -static -nostdinc -o hi hi.o
$ wc -c ./hi
456 ./hi
$ objdump -hd -Mintel ./hi
...
Sections:
Idx Name          Size      VMA               LMA               File off  Algn
  0 .text         00000027  0000000000400080  0000000000400080  00000080  2**4
                  CONTENTS, ALLOC, LOAD, READONLY, CODE
  1 .rodata       00000004  00000000004000a8  00000000004000a8  000000a8  2**2
                  CONTENTS, ALLOC, LOAD, READONLY, DATA

Disassembly of section .text:

0000000000400080 <.text>:
  400080:	b8 01 00 00 00       	mov    eax,0x1
  400085:	bf 01 00 00 00       	mov    edi,0x1
  40008a:	48 be a8 00 40 00 00 	movabs rsi,0x4000a8
  400091:	00 00 00
  400094:	ba 04 00 00 00       	mov    edx,0x4
  400099:	0f 05                	syscall
  40009b:	b8 3c 00 00 00       	mov    eax,0x3c
  4000a0:	bf 00 00 00 00       	mov    edi,0x0
  4000a5:	0f 05                	syscall

 

First, we replace the unnecessary 5 byte instructions with smaller equivalents:

diff --git a/src/asm-naive/hi.s b/src/asm-naive/hi.s
index 9d17cab..3694091 100644
--- a/src/asm-naive/hi.s
+++ b/src/asm-naive/hi.s
@@ -14,12 +14,12 @@ section .rodata
 section .text

 _start:
-  mov rax, 1 ; write
-  mov rdi, 1 ; fd
+  inc al ; write
+  inc edi; fd
   mov rsi, hi ; msg
-  mov rdx, len ; len
+  mov dl, len ; len
   syscall ; call write()

-  mov rax, 60 ; exit
-  mov rdi, 0 ; exit code
+  mov al, 60 ; exit
+  xor edi, edi ; exit code
   syscall ; call exit()

 

Notes:

  • inc al works because Linux zeros registers on process init.
  • inc edi is 2 bytes. Another 2 byte option is mov edi, eax. The other candidates (inc dil, inc di, mov dil, al, and mov di, ax) are all 3 bytes.
  • xor edi, edi is 2 bytes. The other candidates (mov dil, 0, mov di, 0, mov edi, 0, xor dil, dil, xor di, di, and xor rdi, rdi) are all 3-5 bytes.

These changes shrink the binary size to 440 bytes, with 24 bytes of code and 4 bytes of data:

$ make
nasm -f elf64 -o hi.o hi.s
ld -s -static -nostdinc -o hi hi.o
$ wc -c ./hi
440 ./hi
$ objdump -hd -Mintel ./hi
...
Sections:
Idx Name          Size      VMA               LMA               File off  Algn
  0 .text         00000018  0000000000400080  0000000000400080  00000080  2**4
                  CONTENTS, ALLOC, LOAD, READONLY, CODE
  1 .rodata       00000004  0000000000400098  0000000000400098  00000098  2**2
                  CONTENTS, ALLOC, LOAD, READONLY, DATA

Disassembly of section .text:

0000000000400080 <.text>:
  400080:	fe c0                	inc    al
  400082:	ff c7                	inc    edi
  400084:	48 be 98 00 40 00 00 	movabs rsi,0x400098
  40008b:	00 00 00
  40008e:	b2 04                	mov    dl,0x4
  400090:	0f 05                	syscall
  400092:	b0 3c                	mov    al,0x3c
  400094:	31 ff                	xor    edi,edi
  400096:	0f 05                	syscall

 

The code is now 24 bytes, of which 10 are one large mov instruction.

We can drop 2 bytes of code, 4 bytes of data, and the .rodata section by doing the following:

  1. Remove mov rsi, str (-10 bytes, good riddance).
  2. Drop the .rodata section (-4 bytes of data plus .rodata section overhead).
  3. Encode "hi!\n" as a 32-bit integer and push it to the stack (+5 bytes). Hint: "hi!\n" = 68 69 21 0a, encoded as 0x0a216968 plus one byte for push.
  4. Copy rsp to rsi (+3 bytes). This gives write a valid pointer.

Here’s the result:

bits 64

; "hi!\n", encoded as 32-bit little-endian int
str: equ 0x0a216968

section .text
global _start
_start:
  push dword str  ; push str (68 68 69 21 0a)

  inc al          ; write() (fe c0)
  inc edi         ; fd (ff c7)
  mov rsi, rsp    ; msg (48 89 e6)
  mov dl, 4       ; len (b2 04)
  syscall         ; call write() (0f 05)

  mov al, 60      ; exit() (b0 3c)
  xor edi, edi    ; exit code (31 ff)
  syscall         ; call exit() (0f 05)

 

This produces a 360 byte binary with 22 bytes of code and no data section:

$ make
nasm -f elf64 -o hi.o hi.s
ld -s -static -nostdinc -o hi hi.o
$ ./hi
hi!
$ wc -c ./hi
360 ./hi
$ objdump -h ./hi
...
Sections:
Idx Name          Size      VMA               LMA               File off  Algn
  0 .text         00000016  0000000000400080  0000000000400080  00000080  2**4
                  CONTENTS, ALLOC, LOAD, READONLY, CODE

 

This is the smallest legitimate assembly implementation that I could cook up. It’s available in the companion GitHub repository and shown in the results as asm-opt.

Dirty Tricks

In Tiny ELF Files: Revisited in 2021, Nathan Otterness created a 114 byte static x86-64 Linux binary by overlapping portions of the ELF header with the program header, then embedding the code in unverified* gaps of the ELF header.

(* Unverified by Linux, that is. Junk in these fields causes readelf and objdump give these binaries the stink eye, as we’ll see shortly).

Nathan also created a handy table showing which ELF header bytes are unverified by Linux. In particular, there are two unverified 12 byte regions at offsets 4 and 40 which could store our 22 bytes of code.

I reordered the code and divided it into into two chunks:

  • code_0: First 10 bytes, plus a two byte jump to code_1
  • code_1: Remaining 12 bytes.

Here’s the assembly for the two chunks:

; entry point
; (10 bytes of code plus a 2 byte jump to code_1)
code_0:
  push dword str  ; push string onto stack (68 68 69 21 0a)
  inc al          ; write() (fe c0)
  mov rsi, rsp    ; str (48 89 e6)
  jmp code_1      ; jump to next chunk (eb 18)

; ...

; second code chunk
; (12 bytes)
code_1:
  inc edi         ; fd (89 c7)
  mov dl, 4       ; len (b2 04)
  syscall         ; call write() (0f 05)

  mov al, 60      ; exit() (b0 3c)
  xor edi, edi    ; 0 exit code (31 ff)
  syscall         ; call exit() (0f 05)

 

These changes shrink the binary to 114 bytes. Linux will still happily execute the binary, but readelf, objdump, and file can’t make sense of it:

$ make
nasm -f bin -o hi hi.s
chmod a+x hi
$ ./hi
hi!
$ wc -c ./hi
114 ./hi
$ objdump -hd -Mintel ./hi
objdump: ./hi: file format not recognized
$ readelf -SW ./hi
There are 65329 section headers, starting at offset 0x3a:
readelf: Warning: The e_shentsize field in the ELF header is larger than the size of an ELF section header
readelf: Error: Reading 1014951344 bytes extends past end of file for section headers
readelf: Error: Too many program headers - 0x50f - the file is not that big
$ file ./hi
./hi: ELF, unknown class 104
$ hd ./hi
00000000  7f 45 4c 46 68 68 69 21  0a fe c0 48 89 e6 eb 18  |.ELFhhi!...H....|
00000010  02 00 3e 00 01 00 00 00  04 80 02 00 00 00 00 00  |..>.............|
00000020  3a 00 00 00 00 00 00 00  ff c7 b2 04 0f 05 b0 3c  |:..............<|
00000030  31 ff 0f 05 40 00 38 00  01 00 01 00 00 00 05 00  |1...@.8.........|
00000040  00 00 00 00 00 00 00 00  00 00 00 80 02 00 00 00  |................|
00000050  00 00 00 00 00 00 00 00  00 00 72 00 00 00 00 00  |..........r.....|
00000060  00 00 72 00 00 00 00 00  00 00 00 00 00 00 00 00  |..r.............|
00000070  00 00                                             |..|
00000072

 

It’ll even run as a Docker image:

$ cat Dockerfile
FROM scratch
COPY ./hi /hi
ENTRYPOINT ["/hi"]
$ docker build -t zoinks .
Sending build context to Docker daemon  8.704kB
Step 1/3 : FROM scratch
 --->
Step 2/3 : COPY ./hi /hi
 ---> 336acd7e2d94
Step 3/3 : ENTRYPOINT ["/hi"]
 ---> Running in e20eca61de44
Removing intermediate container e20eca61de44
 ---> 297e5d7db5f8
Successfully built 297e5d7db5f8
Successfully tagged zoinks:latest
$ docker images zoinks --format '{{.Size}}'
114B
$ docker run --rm -it zoinks
hi!
$

 

This glorious monstrosity is included in the companion repository and shown in the results as asm-elf.

If you enjoyed this post, you may also like:

Update (2022-01-02): Shorten, fix typos, improve grammar.

Tiny Binaries

December 31, 2021

Out of curiousity I experimented with building the smallest possible static x86-64 Linux binaries in several programming languages.

Each binary does the following:

  1. Print hi! and a newline to standard output.
  2. Return an exit code of 0.

I tested Assembly, C, Go, and Rust with various combinations of optimizations and build options.

Here’s a plot of the results (note: log scale X axis):

All Static Binary Sizes

All Static Binary Sizes

Here’s a plot of the smallest static binary sizes (<1k, linear scale X axis):

Tiny Static Binary Sizes (&lt;1k)

Tiny Static Binary Sizes (<1k)

Full Disclosure: asm-opt is the smallest legitimate result; asm-elf uses dirty tricks from Tiny ELF Files: Revisited in 2021.

Source code, build instructions, a CSV of results, and additional details are available in the companion GitHub repository.

Update (2022-01-01): See Tiny Binaries: Assembly Optimization for an explanation of the assembly results.

Mathyd: Easy TeX to SVG

December 5, 2021

A few weeks ago I released Mathyd, a Docker image containing an HTTP daemon which converts TeX to SVG.

Mathyd includes a minimal command-line client which reads TeX from standard input and prints the generated SVG to standard output, like so:

# set URL and HMAC secret key
# (note: replace value of MATHYD_HMAC_KEY with your own randomly
# generated one)
export MATHYD_URL="http://whatever.example.com:3000/"
export MATHYD_HMAC_KEY="2dhXA3HTmfEMq2d5"

# render output
bin/mathy < cubic.tex > cubic.svg

 

Given this input file, the command above produces the following result:

The Cubic Formula, rendered by Mathyd.

The Cubic Formula, rendered by Mathyd.

Installation

You can install and run the Mathyd Docker image with a single command:

# run mathyd as a daemon on port 3000
# (note: replace value of MATHYD_HMAC_KEY with your own randomly
# generated one)
docker run --rm -d -e MATHYD_HMAC_KEY="2dhXA3HTmfEMq2d5" -p 3000:3000 pablotron/mathyd:latest

 

Notes:

  • Be sure to generate your own HMAC secret key rather than reusing the key from the examples above.
  • Don’t expose Mathyd via a publicly-accessible URL; it does not support TLS and MathJax may use a lot of memory for large input files. If you really do want to do this, then you’ll need to proxy the Mathyd endpoint behind Apache or nginx on an authenticated, TLS-encrypted URL.

Technical Details

Under the hood, Mathyd is just:

  1. a container running an Express HTTP daemon that exposes a single endpoint that accepts a PUT request containing:

    • A JSON-encoded body of input parameters.
    • A hex-encoded, SHA-256 HMAC of the body and the HMAC secret key in the x-mathyd-hmac-sha256 header.

    The endpoint does the following:

    1. Verifies the body HMAC.
    2. Parses the JSON body and extracts the input parameters, including the source TeX.
    3. Converts the input TeX to SVG (via MathJax).
    4. Returns a JSON-encoded response containing the generated SVG.
  2. A command-line client, written in Ruby, which:

    1. Reads TeX from standard input and serializes it as JSON.
    2. Sends a PUT request to the Mathyd daemon and parses the response.
    3. Extracts the generated SVG from the response and writes it to standard output.

Rationale

I wanted an easy way to generate static math SVGs for web pages from the command-line without installing a blizzard of dependencies and without requiring MathJax on the destination page.

I prefer TeX over other formats because it’s still the least-worst format for complex math formulas.

I prefer SVGs over bitmap images whenever possible because:

  • SVGs scale to any screen size and resolution. This is particularly useful for responsive design.
  • SVGs are supported by all modern browsers, including mobile browsers.

Fun fact: even the animated logo for this page is an SVG.

Note: If you want a web interface to noodle around with TeX, check out Mathy instead.

The Birthday Paradox

November 11, 2021

How many people have to be in a room before the probability that there is a shared birthday is greater than 50%?

This is called the Birthday Problem, and the solution is known as the birthday paradox. It is interesting because the answer is counterintuitive and the ramifications affect the security of cryptographic hash algorithms.

The explanation is a bit long for a blog post, so I wrote a full article:

The Birthday Paradox

Wireguard is Awesome

November 6, 2021

I’ve been using WireGuard since late 2019. Several months ago I installed the Android client on my phone and tablet, and the Windows client in a Windows 10 VM.

A few months ago I was able to disable external SSH access to my home network and public servers, and a few weeks ago disabled external IMAPS access too.

What’s so great about WireGuard?

Here’s a complete WireGuard client configuration file from my laptop with the keys, hosts, and subnets changed:

[Interface]
PrivateKey = sEJqK6KqBVkYdMi/66ORZXyD5NFzVcPcq/m0/Sd29m0=
Address = 192.168.43.1/32

[Peer]
PublicKey = WMoOWb0FMF516mGgKMyQefjMvD7xTO8NNCrQJJQnpUE=
PresharedKey = jhhJ1oFjHKEZ8pMK+hmar9SaQEQtJrd2lW6710kQ/d8=
EndPoint = vpn.example.com:53141
AllowedIPs = 192.168.42.0/24

 

That’s it.

If you’ve ever struggled with the mountain of configuration needed for IPsec or a TLS VPN like OpenVPN, then the example above should be a breathe of fresh air.

By the way, if you’re trying to route traffic from a client on a common reserved subnet (ex: 192.168.1.0/24) to network behind a VPN with the same subnet, take a look at the DNATs and Maps section of my NFtables Examples article.

Feed Bloater

November 5, 2021

In addition to fixing the RSS feed for this site, I also created a simple command-line tool named Feed Bloater which expands truncated RSS 2.0 feeds by doing the following:

  1. Fetch the contents of the feed.
  2. Fetch the HTML from the <link> for each feed item.
  3. Filter the HTML from the previous step based on a CSS selector.
  4. Replace the truncated item descriptions with the HTML from the previous step.
  5. Write a new RSS feed to the given output path.

Feed Bloater maintains an internal cache and respects the ETag and Last-Modified headers, and by default it won’t update the output file if the source feed has not changed.

Here is an example that uses Feed Bloater to expand the truncated LLVM Weekly RSS feed:

feedbloater https://llvmweekly.org/rss.xml div.post path/to/llvmweekly.xml

 

Here’s what the original LLVM Weekly RSS feed looks like in The Old Reader:

Truncated LLVM Weekly RSS feed, viewed in The Old Reader.

Truncated LLVM Weekly RSS feed, viewed in The Old Reader.

Here’s what the expanded RSS feed generated by the example above looks like:

LLVM Weekly RSS feed, expanded by Feed Bloater, and viewed in The Old Reader.

LLVM Weekly RSS feed, expanded by Feed Bloater, and viewed in The Old Reader.

Much better! I’ve been happily using Feed Bloater to expand several truncated feeds for about a week.

If you’re interested in trying Feed Bloater, you can find installation, usage, and configuration instructions in the documentation on the Feed Bloater GitHub Repository.

RSS Feed No Longer Annoyingly Trunca...

October 26, 2021

Hugo’s default RSS feed template truncates RSS feeds by default. Long time readers probably know how I feel about that.

I copied the default Hugo RSS feed template and made the following changes:

  • Limited the feed to pages in the “Posts” section (Note: this may not work for your site).
  • Set the default post limit to 15 posts, because Hugo was trying to include all posts in the RSS feed.
  • Changed feed items to include complete post contents as HTML.

The updated template is available here. You can use it on your site by copying it to layouts/_default/index.xml.

The Nuclear Option (No More unsafe-inline)

October 25, 2021

As you can see from the last post, I went with the nuclear option and created a Hugo table shortcode, then did the following:

  1. Updated all the tables on the site to use the new table shortcode.
  2. Removed style-src 'self' 'unsafe-inline' from the Content-Security-Policy header.
  3. Re-ran the Security Headers scan.

Here is the updated Content-Security-Policy from the Apache config:

# look ma, no unsafe-inline!
Header append "Content-Security-Policy" "default-src 'self'; img-src 'self' https://pmdn.org"

 

And here is the updated Security Headers scan result:

Updated Security Headers scan result.

Updated Security Headers scan result.

A couple of recommendations for folks getting started with Hugo:

  1. Do not use <img>; use the figure shortcode instead. The latter is far more flexible and also works well with a responsive design.
  2. If you are embedding complex tables or you are generating tables with alignment and want to avoid inline style attributes, do not use the Markdown table syntax. Use hugo-shortcode-table instead.

Table Shortcode for Hugo

October 25, 2021

I just posted hugo-shortcode-table, a shortcode for Hugo which allows you to generate CSS-only (no inline style attributes) tables.

Features:

  • Much more powerful than built-in Markdown table syntax.
  • Emits CSS classes for alignment rather than inline style attributes (no more style-src unsafe-inline).
  • Table can be defined as front matter or site data.
  • Column tooltips.
  • Cell-specific overrides (alignment, tooltip, etc).
  • HTML and Markdown markup support.
  • Easy to configure emitted CSS classes for a variety of frameworks, including Bulma and Bootstrap.

Links:

Archived Posts...
© 1998-2022 Paul Duncan