Skip to content

Commit

Permalink
learn: Add HTML output
Browse files Browse the repository at this point in the history
Add HTML output to question model and export command. Use HTML <form> element
in PrintHTML(buffer *bytes.Buffer) method on question.Model to create
question HTML fragment. The ToHTML() method returns a complete standalone
HTML document for a single question with some basic styling.

In a follow up commit we will combine multiple questions into a single HTML
page, representing an exercise. The golden HTML file can already be previewed
in the browser.
  • Loading branch information
juliaogris committed May 17, 2024
1 parent 56cc298 commit 8d94569
Show file tree
Hide file tree
Showing 25 changed files with 1,293 additions and 17 deletions.
1 change: 1 addition & 0 deletions .golangci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ issues:
- "^G302: Expect file permissions to be 0600 or less"
- "^G304: Potential file inclusion via variable"
- "^G306: Expect WriteFile permissions to be 0600 or less"
- "^G301: Expect directory permissions to be 0750 or less"

linters:
enable-all: true
Expand Down
2 changes: 1 addition & 1 deletion build-tools/md/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ func (a *app) copy() ([]string, error) {

if d.IsDir() {
// use MkdirAll in case the directory already exists
return os.MkdirAll(destfile, 0o777) //nolint:gosec
return os.MkdirAll(destfile, 0o777)
}

if filepath.Ext(filename) == ".md" {
Expand Down
2 changes: 1 addition & 1 deletion build-tools/site-gen/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ func (a *app) copyTree() error {

switch mode := d.Type() & fs.ModeType; mode {
case fs.ModeDir:
return os.Mkdir(destfile, 0o777) //nolint:gosec // erroneous linter
return os.Mkdir(destfile, 0o777)
case fs.ModeSymlink:
if err := checkSymlink(srcfile); err != nil {
return err
Expand Down
2 changes: 1 addition & 1 deletion learn/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ It currently supports the following sub-commands:
Try it with

make install
levy export pkg/question/testdata/course1/unit1/exercise1/questions/question1.md
levy export answerkey pkg/question/testdata/course1/unit1/exercise1/questions/question1.md
levy seal pkg/question/testdata/course1/unit1/exercise1/questions/question1.md

For sample error messages in case of failed verification, try
Expand Down
65 changes: 53 additions & 12 deletions learn/cmd/levy/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@
// -V, --version Print version information
//
// Commands:
// export <md-file> [<answer-key-file>] [flags]
// Export answer key File.
// export <export-type> <md-file> [<target>] [flags]
// Export answer key and HTML Files.
//
// verify <md-file> [<type>] [flags]
// Verify answers in markdown file.
Expand All @@ -30,6 +30,8 @@ import (
"cmp"
"fmt"
"os"
"path/filepath"
"strings"

"evylang.dev/evy/learn/pkg/question"
"github.com/alecthomas/kong"
Expand All @@ -42,7 +44,7 @@ levy is a tool that manages learn and practice resources for Evy.
var version = "v0.0.0"

type app struct {
Export exportCmd `cmd:"" help:"Export answer key File."`
Export exportCmd `cmd:"" help:"Export answer key and HTML Files."`
Verify verifyCmd `cmd:"" help:"Verify answers in markdown file."`

Seal sealCmd `cmd:"" help:"Move 'answer' to 'sealed-answer' in source markdown."`
Expand All @@ -63,10 +65,14 @@ func main() {
}

type exportCmd struct {
MDFile string `arg:"" help:"Question markdown file." placeholder:"MDFILE"`
AnswerKeyFile string `arg:"" default:"-" help:"JSON output file for answer key (default: stdout)." placeholder:"JSONFILE"`
UnsealedOnly bool `short:"u" help:"Only export files with unsealed answers. Suitable if private key not available."`
PrivateKey string `short:"k" help:"Secret private key to decrypt sealed answers." env:"EVY_LEARN_PRIVATE_KEY"`
ExportType string `arg:"" enum:"html,answerkey,all" help:"Export target: one of html, answerkey, all."`
MDFile string `arg:"" help:"Question markdown file." placeholder:"MDFILE"`
Target string `arg:"" default:"-" help:"Output directory or JSON/HTML output file (default: . | stdout)." placeholder:"TARGET"`
UnsealedOnly bool `short:"u" help:"Only export files with unsealed answers. Suitable if private key not available."`
PrivateKey string `short:"k" help:"Secret private key to decrypt sealed answers." env:"EVY_LEARN_PRIVATE_KEY"`

htmlPath string
answerKeyPath string
}

type verifyCmd struct {
Expand Down Expand Up @@ -94,14 +100,49 @@ func (c *exportCmd) Run() error {
if err != nil {
return err
}
answerKeyJSON, err := model.ExportAnswerKeyJSON()
if err != nil {
if err := c.setPaths(); err != nil {
return err
}
if c.AnswerKeyFile != "-" {
return os.WriteFile(c.AnswerKeyFile, []byte(answerKeyJSON), 0o666)
if c.ExportType == "answerkey" || c.ExportType == "all" {
answerKeyJSON, err := model.ExportAnswerKeyJSON()
if err != nil {
return err
}
if err := writeFileOrStdout(c.answerKeyPath, answerKeyJSON); err != nil {
return err
}
}
if c.ExportType == "html" || c.ExportType == "all" {
if err := writeFileOrStdout(c.htmlPath, model.ToHTML()); err != nil {
return err
}
}
return nil
}

func writeFileOrStdout(filename, content string) error {
if filename == "-" {
fmt.Println(content)
return nil
}
return os.WriteFile(filename, []byte(content), 0o666)
}

func (c *exportCmd) setPaths() error {
c.htmlPath = c.Target
c.answerKeyPath = c.Target
if c.ExportType == "all" {
if c.Target == "-" { // default
c.Target = "."
} else {
if err := os.MkdirAll(c.Target, 0o755); err != nil {
return err
}
}
htmlFile := strings.TrimSuffix(filepath.Base(c.MDFile), filepath.Ext(c.MDFile)) + ".html"
c.htmlPath = filepath.Join(c.Target, htmlFile)
c.answerKeyPath = filepath.Join(c.Target, "answerkey.json")
}
fmt.Println(answerKeyJSON)
return nil
}

Expand Down
145 changes: 145 additions & 0 deletions learn/pkg/question/question.go
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
package question

import (
"bytes"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strings"

"rsc.io/markdown"
Expand Down Expand Up @@ -46,8 +48,14 @@ type Model struct {

withSealed bool
privateKey string
embeds map[markdown.Block]embed // use to replace markdown Link or Image with codeBlock or inline SVG
answerList markdown.Block // use to output checkbox or radio buttons for List in HTML
}

type embed struct {
id string
renderer Renderer
}
type fieldType uint

const (
Expand All @@ -74,6 +82,7 @@ func NewModel(filename string, options ...Option) (*Model, error) {
Filename: filename,
Doc: doc,
Frontmatter: frontmatter,
embeds: map[markdown.Block]embed{},
}
for _, opt := range options {
opt(model)
Expand Down Expand Up @@ -165,6 +174,55 @@ func (m *Model) WriteFormatted() error {
return os.WriteFile(m.Filename, b, 0o666)
}

// PrintHTML prints the question and answer choices as HTML form elements.
func (m *Model) PrintHTML(buf *bytes.Buffer) {
buf.WriteString("<form id=" + baseFilename(m.Filename) + ">\n")
for _, block := range m.Doc.Blocks {
if block == m.answerList {
m.printAnswerChoicesHTML(block.(*markdown.List), buf)
continue
}
if embed, ok := m.embeds[block]; ok {
embed.renderer.RenderHTML(buf)
continue
}
block.PrintHTML(buf)
}
buf.WriteString("</form>\n")
}

// ToHTML returns a complete standalone HTML document as string.
func (m *Model) ToHTML() string {
buf := &bytes.Buffer{}
buf.WriteString(questionPrefixHTML)
m.PrintHTML(buf)
buf.WriteString(questionSuffixHTML)
return buf.String()
}

func baseFilename(filename string) string {
return strings.TrimSuffix(filepath.Base(filename), filepath.Ext(filename))
}

func (m *Model) printAnswerChoicesHTML(list *markdown.List, buf *bytes.Buffer) {
buf.WriteString("<fieldset>\n")
for i, item := range list.Items {
letter := indexToLetter(i)
buf.WriteString("<div>\n")
buf.WriteString(`<label for="` + letter + `">` + letter + "</label>\n")
buf.WriteString(`<input type="radio" id="` + letter + `" name="answer" />` + "\n")
for _, block := range item.(*markdown.Item).Blocks {
if embed, ok := m.embeds[block]; ok {
embed.renderer.RenderHTML(buf)
} else {
block.PrintHTML(buf)
}
}
buf.WriteString("</div>\n")
}
buf.WriteString("</fieldset>\n")
}

func (m *Model) getVerifiedAnswer() (Answer, error) {
answer, err := m.Frontmatter.getAnswer(m.privateKey)
if err != nil {
Expand Down Expand Up @@ -219,6 +277,7 @@ func (m *Model) buildQuestionField(b markdown.Block) error {
if err != nil {
return err
}
m.trackBlocksToReplace(b, m.Question)
return nil
}

Expand All @@ -242,6 +301,8 @@ func (m *Model) buildAnswerChoicesField(block markdown.Block) error {
}
found = true
m.AnswerChoices = append(m.AnswerChoices, renderer)
m.answerList = block
m.trackBlocksToReplace(b, renderer)
}
}
if len(m.AnswerChoices) != 0 && len(m.AnswerChoices) != len(list.Items) {
Expand All @@ -250,6 +311,33 @@ func (m *Model) buildAnswerChoicesField(block markdown.Block) error {
return nil
}

func (m *Model) trackBlocksToReplace(b markdown.Block, renderer Renderer) {
text := toText(b)
if renderer == nil || text == nil || len(text.Inline) != 1 {
return
}
id := idFromInline(text.Inline[0])
if id == "" {
return
}
m.embeds[b] = embed{id: id, renderer: renderer}
}

func idFromInline(inline markdown.Inline) string {
switch i := inline.(type) {
case *markdown.Link:
return escape(i.URL)
case *markdown.Image:
return escape(i.URL)
}
return ""
}

func escape(s string) string {
s = strings.ReplaceAll(s, "/", "-")
return strings.ReplaceAll(s, ".", "-")
}

func (m *Model) inferResultType() error {
resultType := UnknownOutput
renderers := append([]Renderer{m.Question}, m.AnswerChoices...)
Expand Down Expand Up @@ -277,3 +365,60 @@ func (m *Model) setPrivateKey(privateKey string) {
m.privateKey = privateKey
m.withSealed = true
}

const questionPrefixHTML = `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>evy · Question</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>⚡️</text></svg>" />
<style>
body {
padding: 8px 32px;
margin: 0;
}
fieldset {
display: grid;
grid-template: repeat(2, min-content) / repeat(2, min-content);
grid-auto-flow: column;
column-gap: 32px;
row-gap: 8px;
border: none;
}
pre {
border: 1px solid silver;
padding: 8px 12px;
border-radius: 2px;
background: whitesmoke;
width: fit-content;
}
fieldset > div {
display: flex;
align-items: start;
gap: 8px;
border: 1px solid silver;
border-radius: 6px;
padding: 8px;
background: whitesmoke;
}
fieldset pre {
margin: 0;
padding: 4px 12px;
align-self: center;
background: white;
border: 1px solid silver
}
form svg {
width: 200px;
height: 200px;
border: 1px solid silver;
}
</style>
</head>
<body>
`

const questionSuffixHTML = ` </body>
</html>
`

0 comments on commit 8d94569

Please sign in to comment.