Add education-specific extensions

not all of them yet
This commit is contained in:
Ben Visness 2022-09-01 20:16:09 -05:00
parent d5bdb9a7af
commit 98b9ef6589
12 changed files with 237 additions and 189 deletions

View File

@ -2,47 +2,18 @@
// Use of this source code is governed by a BSD-style // Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file. // license that can be found in the LICENSE file.
"use strict";
(() => { (() => {
// Map multiple JavaScript environments to a single common API,
// preferring web standards over Node.js API.
//
// Environments considered:
// - Browsers
// - Node.js
// - Electron
// - Parcel
// - Webpack
if (typeof global !== "undefined") {
// global already exists
} else if (typeof window !== "undefined") {
window.global = window;
} else if (typeof self !== "undefined") {
self.global = self;
} else {
throw new Error("cannot export Go (neither global, window nor self is defined)");
}
if (!global.require && typeof require !== "undefined") {
global.require = require;
}
if (!global.fs && global.require) {
const fs = require("fs");
if (typeof fs === "object" && fs !== null && Object.keys(fs).length !== 0) {
global.fs = fs;
}
}
const enosys = () => { const enosys = () => {
const err = new Error("not implemented"); const err = new Error("not implemented");
err.code = "ENOSYS"; err.code = "ENOSYS";
return err; return err;
}; };
if (!global.fs) { if (!globalThis.fs) {
let outputBuf = ""; let outputBuf = "";
global.fs = { globalThis.fs = {
constants: { O_WRONLY: -1, O_RDWR: -1, O_CREAT: -1, O_TRUNC: -1, O_APPEND: -1, O_EXCL: -1 }, // unused constants: { O_WRONLY: -1, O_RDWR: -1, O_CREAT: -1, O_TRUNC: -1, O_APPEND: -1, O_EXCL: -1 }, // unused
writeSync(fd, buf) { writeSync(fd, buf) {
outputBuf += decoder.decode(buf); outputBuf += decoder.decode(buf);
@ -87,8 +58,8 @@
}; };
} }
if (!global.process) { if (!globalThis.process) {
global.process = { globalThis.process = {
getuid() { return -1; }, getuid() { return -1; },
getgid() { return -1; }, getgid() { return -1; },
geteuid() { return -1; }, geteuid() { return -1; },
@ -102,47 +73,26 @@
} }
} }
if (!global.crypto && global.require) { if (!globalThis.crypto) {
const nodeCrypto = require("crypto"); throw new Error("globalThis.crypto is not available, polyfill required (crypto.getRandomValues only)");
global.crypto = {
getRandomValues(b) {
nodeCrypto.randomFillSync(b);
},
};
}
if (!global.crypto) {
throw new Error("global.crypto is not available, polyfill required (getRandomValues only)");
} }
if (!global.performance) { if (!globalThis.performance) {
global.performance = { throw new Error("globalThis.performance is not available, polyfill required (performance.now only)");
now() {
const [sec, nsec] = process.hrtime();
return sec * 1000 + nsec / 1000000;
},
};
} }
if (!global.TextEncoder && global.require) { if (!globalThis.TextEncoder) {
global.TextEncoder = require("util").TextEncoder; throw new Error("globalThis.TextEncoder is not available, polyfill required");
}
if (!global.TextEncoder) {
throw new Error("global.TextEncoder is not available, polyfill required");
} }
if (!global.TextDecoder && global.require) { if (!globalThis.TextDecoder) {
global.TextDecoder = require("util").TextDecoder; throw new Error("globalThis.TextDecoder is not available, polyfill required");
} }
if (!global.TextDecoder) {
throw new Error("global.TextDecoder is not available, polyfill required");
}
// End of polyfills for common API.
const encoder = new TextEncoder("utf-8"); const encoder = new TextEncoder("utf-8");
const decoder = new TextDecoder("utf-8"); const decoder = new TextDecoder("utf-8");
global.Go = class { globalThis.Go = class {
constructor() { constructor() {
this.argv = ["js"]; this.argv = ["js"];
this.env = {}; this.env = {};
@ -296,8 +246,8 @@
setInt64(sp + 8, (timeOrigin + performance.now()) * 1000000); setInt64(sp + 8, (timeOrigin + performance.now()) * 1000000);
}, },
// func walltime1() (sec int64, nsec int32) // func walltime() (sec int64, nsec int32)
"runtime.walltime1": (sp) => { "runtime.walltime": (sp) => {
sp >>>= 0; sp >>>= 0;
const msec = (new Date).getTime(); const msec = (new Date).getTime();
setInt64(sp + 8, msec / 1000); setInt64(sp + 8, msec / 1000);
@ -401,6 +351,7 @@
storeValue(sp + 56, result); storeValue(sp + 56, result);
this.mem.setUint8(sp + 64, 1); this.mem.setUint8(sp + 64, 1);
} catch (err) { } catch (err) {
sp = this._inst.exports.getsp() >>> 0; // see comment above
storeValue(sp + 56, err); storeValue(sp + 56, err);
this.mem.setUint8(sp + 64, 0); this.mem.setUint8(sp + 64, 0);
} }
@ -417,6 +368,7 @@
storeValue(sp + 40, result); storeValue(sp + 40, result);
this.mem.setUint8(sp + 48, 1); this.mem.setUint8(sp + 48, 1);
} catch (err) { } catch (err) {
sp = this._inst.exports.getsp() >>> 0; // see comment above
storeValue(sp + 40, err); storeValue(sp + 40, err);
this.mem.setUint8(sp + 48, 0); this.mem.setUint8(sp + 48, 0);
} }
@ -433,6 +385,7 @@
storeValue(sp + 40, result); storeValue(sp + 40, result);
this.mem.setUint8(sp + 48, 1); this.mem.setUint8(sp + 48, 1);
} catch (err) { } catch (err) {
sp = this._inst.exports.getsp() >>> 0; // see comment above
storeValue(sp + 40, err); storeValue(sp + 40, err);
this.mem.setUint8(sp + 48, 0); this.mem.setUint8(sp + 48, 0);
} }
@ -514,7 +467,7 @@
null, null,
true, true,
false, false,
global, globalThis,
this, this,
]; ];
this._goRefCounts = new Array(this._values.length).fill(Infinity); // number of references that Go has to a JS value, indexed by reference id this._goRefCounts = new Array(this._values.length).fill(Infinity); // number of references that Go has to a JS value, indexed by reference id
@ -523,7 +476,7 @@
[null, 2], [null, 2],
[true, 3], [true, 3],
[false, 4], [false, 4],
[global, 5], [globalThis, 5],
[this, 6], [this, 6],
]); ]);
this._idPool = []; // unused ids that have been garbage collected this._idPool = []; // unused ids that have been garbage collected
@ -564,6 +517,13 @@
offset += 8; offset += 8;
}); });
// The linker guarantees global data starts from at least wasmMinDataAddr.
// Keep in sync with cmd/link/internal/ld/data.go:wasmMinDataAddr.
const wasmMinDataAddr = 4096 + 8192;
if (offset >= wasmMinDataAddr) {
throw new Error("total length of command line and environment variables exceeds limit");
}
this._inst.exports.run(argc, argv); this._inst.exports.run(argc, argv);
if (this.exited) { if (this.exited) {
this._resolveExitPromise(); this._resolveExitPromise();
@ -591,36 +551,4 @@
}; };
} }
} }
if (
typeof module !== "undefined" &&
global.require &&
global.require.main === module &&
global.process &&
global.process.versions &&
!global.process.versions.electron
) {
if (process.argv.length < 3) {
console.error("usage: go_js_wasm_exec [wasm binary] [arguments]");
process.exit(1);
}
const go = new Go();
go.argv = process.argv.slice(2);
go.env = Object.assign({ TMPDIR: require("os").tmpdir() }, process.env);
go.exit = process.exit;
WebAssembly.instantiate(fs.readFileSync(process.argv[2]), go.importObject).then((result) => {
process.on("exit", (code) => { // Node.js exits if no event handler is pending
if (code === 0 && !go.exited) {
// deadlock, make Go print error and stack traces
go._pendingEvent = { id: 0 };
go._resume();
}
});
return go.run(result.instance);
}).catch((err) => {
console.error(err);
process.exit(1);
});
}
})(); })();

Binary file not shown.

View File

@ -28,87 +28,45 @@ var reTag = regexp.MustCompile(`\[\s*(?P<opentagname>[a-zA-Z0-9]+)|\[\s*\/\s*(?P
var previewBBCodeCompiler = bbcode.NewCompiler(false, false) var previewBBCodeCompiler = bbcode.NewCompiler(false, false)
var realBBCodeCompiler = bbcode.NewCompiler(false, false) var realBBCodeCompiler = bbcode.NewCompiler(false, false)
var eduPreviewBBCodeCompiler = bbcode.NewCompiler(false, false)
var eduRealBBCodeCompiler = bbcode.NewCompiler(false, false)
var REYoutubeVidOnly = regexp.MustCompile(`^[a-zA-Z0-9_-]{11}$`) var REYoutubeVidOnly = regexp.MustCompile(`^[a-zA-Z0-9_-]{11}$`)
func init() { func init() {
type attr struct { all := []bbcode.Compiler{previewBBCodeCompiler, realBBCodeCompiler, eduPreviewBBCodeCompiler, eduRealBBCodeCompiler}
Name, Value string real := []bbcode.Compiler{realBBCodeCompiler, eduRealBBCodeCompiler}
} preview := []bbcode.Compiler{previewBBCodeCompiler, eduPreviewBBCodeCompiler}
education := []bbcode.Compiler{eduPreviewBBCodeCompiler, eduRealBBCodeCompiler}
addSimpleTag := func(name, tag string, notext bool, attrs ...attr) { addSimpleTag(all, "h1", "h1", false, nil)
var tagFunc bbcode.TagCompilerFunc = func(bn *bbcode.BBCodeNode) (*bbcode.HTMLTag, bool) { addSimpleTag(all, "h2", "h3", false, nil)
if notext { addSimpleTag(all, "h3", "h3", false, nil)
var newChildren []*bbcode.BBCodeNode addSimpleTag(all, "m", "span", false, attrs{"class": "monospace"})
for _, child := range bn.Children { addSimpleTag(all, "ol", "ol", true, nil)
if child.ID != bbcode.TEXT { addSimpleTag(all, "ul", "ul", true, nil)
newChildren = append(newChildren, child) addSimpleTag(all, "li", "li", false, nil)
} addSimpleTag(all, "spoiler", "span", false, attrs{"class": "spoiler"})
} addSimpleTag(all, "table", "table", true, nil)
bn.Children = newChildren addSimpleTag(all, "tr", "tr", true, nil)
} addSimpleTag(all, "th", "th", false, nil)
addSimpleTag(all, "td", "td", false, nil)
out := bbcode.NewHTMLTag("") addTag(all, "quote", func(bn *bbcode.BBCodeNode) (*bbcode.HTMLTag, bool) {
out.Name = tag
for _, a := range attrs {
out.Attrs[a.Name] = a.Value
}
return out, true
}
previewBBCodeCompiler.SetTag(name, tagFunc)
realBBCodeCompiler.SetTag(name, tagFunc)
}
addTag := func(name string, f bbcode.TagCompilerFunc) {
previewBBCodeCompiler.SetTag(name, f)
realBBCodeCompiler.SetTag(name, f)
}
previewBBCodeCompiler.SetTag("youtube", makeYoutubeBBCodeFunc(true))
realBBCodeCompiler.SetTag("youtube", makeYoutubeBBCodeFunc(false))
addSimpleTag("h1", "h1", false)
addSimpleTag("h2", "h3", false)
addSimpleTag("h3", "h3", false)
addSimpleTag("m", "span", false, attr{"class", "monospace"})
addSimpleTag("ol", "ol", true)
addSimpleTag("ul", "ul", true)
addSimpleTag("li", "li", false)
addSimpleTag("spoiler", "span", false, attr{"class", "spoiler"})
addSimpleTag("table", "table", true)
addSimpleTag("tr", "tr", true)
addSimpleTag("th", "th", false)
addSimpleTag("td", "td", false)
addTag("quote", func(bn *bbcode.BBCodeNode) (*bbcode.HTMLTag, bool) {
cite := bn.GetOpeningTag().Value cite := bn.GetOpeningTag().Value
if cite == "" { if cite == "" {
out := bbcode.NewHTMLTag("") return htm("blockquote", nil), true
out.Name = "blockquote"
return out, true
} else { } else {
out := bbcode.NewHTMLTag("") return htm("blockquote", attrs{"cite": cite},
out.Name = "blockquote" htm("a", attrs{"href": hmnurl.BuildUserProfile(cite), "class": "quotewho"},
out.Attrs["cite"] = cite bbcode.NewHTMLTag(cite),
),
a := bbcode.NewHTMLTag("") htm("br", nil),
a.Name = "a" ), true
a.Attrs = map[string]string{
"href": hmnurl.BuildUserProfile(cite),
"class": "quotewho",
}
a.AppendChild(bbcode.NewHTMLTag(cite))
br := bbcode.NewHTMLTag("")
br.Name = "br"
out.AppendChild(a)
out.AppendChild(br)
return out, true
} }
}) })
addTag("code", func(bn *bbcode.BBCodeNode) (*bbcode.HTMLTag, bool) { addTag(all, "code", func(bn *bbcode.BBCodeNode) (*bbcode.HTMLTag, bool) {
lang := "" lang := ""
if tagvalue := bn.GetOpeningTag().Value; tagvalue != "" { if tagvalue := bn.GetOpeningTag().Value; tagvalue != "" {
lang = tagvalue lang = tagvalue
@ -150,6 +108,63 @@ func init() {
return out, false return out, false
}) })
addTag(preview, "youtube", makeYoutubeBBCodeFunc(true))
addTag(real, "youtube", makeYoutubeBBCodeFunc(false))
addTag(education, "glossary", func(bn *bbcode.BBCodeNode) (*bbcode.HTMLTag, bool) {
term := bn.GetOpeningTag().Value
return htm("a", attrs{"href": hmnurl.BuildEducationGlossary(term), "class": "glossary-term", "data-term": term}), true
})
addSimpleTag(education, "note", "div", false, attrs{"class": "education-note"})
}
type attrs map[string]string
// Generates a "simple" tag, i.e. one that is more or less just HTML.
//
// name is the bbcode tag name.
// tag is the HTML tag to generate.
// notext prevents the tag from having any direct text children (useful for e.g. [ol] and [ul])
// attrs are any HTML attributes to add to the tag.
func addSimpleTag(compilers []bbcode.Compiler, name, tag string, notext bool, attributes attrs) {
var tagFunc bbcode.TagCompilerFunc = func(bn *bbcode.BBCodeNode) (*bbcode.HTMLTag, bool) {
if notext {
var newChildren []*bbcode.BBCodeNode
for _, child := range bn.Children {
if child.ID != bbcode.TEXT {
newChildren = append(newChildren, child)
}
}
bn.Children = newChildren
}
out := bbcode.NewHTMLTag("")
out.Name = tag
out.Attrs = attributes
return out, true
}
for _, compiler := range compilers {
compiler.SetTag(name, tagFunc)
}
}
func addTag(compilers []bbcode.Compiler, name string, f bbcode.TagCompilerFunc) {
for _, compiler := range compilers {
compiler.SetTag(name, f)
}
}
// html was taken
func htm(tag string, attributes attrs, children ...*bbcode.HTMLTag) *bbcode.HTMLTag {
res := bbcode.NewHTMLTag("")
res.Name = tag
res.Attrs = attributes
for _, child := range children {
res.AppendChild(child)
}
return res
} }
func makeYoutubeBBCodeFunc(preview bool) bbcode.TagCompilerFunc { func makeYoutubeBBCodeFunc(preview bool) bbcode.TagCompilerFunc {
@ -233,7 +248,8 @@ func makeYoutubeBBCodeFunc(preview bool) bbcode.TagCompilerFunc {
// ---------------------- // ----------------------
type bbcodeParser struct { type bbcodeParser struct {
Preview bool Preview bool
Education bool
} }
var _ parser.InlineParser = &bbcodeParser{} var _ parser.InlineParser = &bbcodeParser{}
@ -291,9 +307,15 @@ func (s bbcodeParser) Parse(parent gast.Node, block text.Reader, pc parser.Conte
unparsedBBCode := restOfSource[:endIndex] unparsedBBCode := restOfSource[:endIndex]
block.Advance(len(unparsedBBCode)) block.Advance(len(unparsedBBCode))
compiler := realBBCodeCompiler var compiler bbcode.Compiler
if s.Preview { if s.Preview && s.Education {
compiler = eduPreviewBBCodeCompiler
} else if s.Preview && !s.Education {
compiler = previewBBCodeCompiler compiler = previewBBCodeCompiler
} else if !s.Preview && s.Education {
compiler = eduRealBBCodeCompiler
} else {
compiler = realBBCodeCompiler
} }
return NewBBCode(compiler.Compile(string(unparsedBBCode))) return NewBBCode(compiler.Compile(string(unparsedBBCode)))
@ -368,12 +390,13 @@ func (r *BBCodeHTMLRenderer) renderBBCode(w util.BufWriter, source []byte, n gas
// ---------------------- // ----------------------
type BBCodeExtension struct { type BBCodeExtension struct {
Preview bool Preview bool
Education bool
} }
func (e BBCodeExtension) Extend(m goldmark.Markdown) { func (e BBCodeExtension) Extend(m goldmark.Markdown) {
m.Parser().AddOptions(parser.WithInlineParsers( m.Parser().AddOptions(parser.WithInlineParsers(
util.Prioritized(bbcodeParser{Preview: e.Preview}, BBCodePriority), util.Prioritized(bbcodeParser{Preview: e.Preview, Education: e.Education}, BBCodePriority),
)) ))
m.Renderer().AddOptions(renderer.WithNodeRenderers( m.Renderer().AddOptions(renderer.WithNodeRenderers(
util.Prioritized(NewBBCodeHTMLRenderer(), BBCodePriority), util.Prioritized(NewBBCodeHTMLRenderer(), BBCodePriority),

View File

@ -46,6 +46,24 @@ var DiscordMarkdown = makeGoldmark(
goldmark.WithRendererOptions(html.WithHardWraps()), goldmark.WithRendererOptions(html.WithHardWraps()),
) )
// Used for rendering real-time previews of post content.
var EducationPreviewMarkdown = makeGoldmark(
goldmark.WithExtensions(makeGoldmarkExtensions(MarkdownOptions{
Previews: true,
Embeds: true,
Education: true,
})...),
)
// Used for generating the final HTML for a post.
var EducationRealMarkdown = makeGoldmark(
goldmark.WithExtensions(makeGoldmarkExtensions(MarkdownOptions{
Previews: false,
Embeds: true,
Education: true,
})...),
)
func ParseMarkdown(source string, md goldmark.Markdown) string { func ParseMarkdown(source string, md goldmark.Markdown) string {
var buf bytes.Buffer var buf bytes.Buffer
if err := md.Convert([]byte(source), &buf); err != nil { if err := md.Convert([]byte(source), &buf); err != nil {
@ -56,8 +74,9 @@ func ParseMarkdown(source string, md goldmark.Markdown) string {
} }
type MarkdownOptions struct { type MarkdownOptions struct {
Previews bool Previews bool
Embeds bool Embeds bool
Education bool
} }
func makeGoldmark(opts ...goldmark.Option) goldmark.Markdown { func makeGoldmark(opts ...goldmark.Option) goldmark.Markdown {
@ -114,7 +133,8 @@ func makeGoldmarkExtensions(opts MarkdownOptions) []goldmark.Extender {
extenders = append(extenders, extenders = append(extenders,
MathjaxExtension{}, MathjaxExtension{},
BBCodeExtension{ BBCodeExtension{
Preview: opts.Previews, Preview: opts.Previews,
Education: opts.Education,
}, },
) )

View File

@ -61,6 +61,22 @@ func main() {
assert.Contains(t, html, "Hello, world!") assert.Contains(t, html, "Hello, world!")
}) })
}) })
t.Run("education", func(t *testing.T) {
t.Run("[glossary]", func(t *testing.T) {
html := ParseMarkdown("[glossary=foo]Foo Protocol[/glossary]", EducationRealMarkdown)
t.Log(html)
assert.Equal(t, 1, strings.Count(html, "<a"))
assert.Contains(t, html, `class="glossary-term"`)
assert.Contains(t, html, `data-term="foo"`)
})
t.Run("[note]", func(t *testing.T) {
html := ParseMarkdown("[note]This should only appear to editors![/note]", EducationRealMarkdown)
t.Log(html)
assert.Equal(t, 1, strings.Count(html, "<div"))
assert.Contains(t, html, `class="education-note"`)
})
})
} }
func TestSharlock(t *testing.T) { func TestSharlock(t *testing.T) {

57
src/parsing/wasm/build.go Normal file
View File

@ -0,0 +1,57 @@
//go:build !js
package main
import (
"fmt"
"go/build"
"io"
"os"
"os/exec"
"path/filepath"
"git.handmade.network/hmn/hmn/src/utils"
)
func main() {
const publicDir = "../../../public"
compile := exec.Command("go", "build", "-o", filepath.Join(publicDir, "parsing.wasm"))
compile.Env = append(os.Environ(), "GOOS=js", "GOARCH=wasm")
run(compile)
utils.Must(copy(
fmt.Sprintf("%s/misc/wasm/wasm_exec.js", build.Default.GOROOT),
filepath.Join(publicDir, "go_wasm_exec.js"),
))
}
func run(cmd *exec.Cmd) {
output, err := cmd.CombinedOutput()
fmt.Print(string(output))
if err != nil {
fmt.Println(err)
if exit, ok := err.(*exec.ExitError); ok {
os.Exit(exit.ExitCode())
}
}
}
func copy(src string, dst string) error {
s, err := os.Open(src)
if err != nil {
return err
}
defer s.Close()
d, err := os.Create(dst)
if err != nil {
return err
}
if _, err := io.Copy(d, s); err != nil {
d.Close()
return err
}
return d.Close()
}

View File

@ -1,6 +0,0 @@
#!/bin/bash
PUBLIC_DIR=../../../public
GOOS=js GOARCH=wasm go build -o $PUBLIC_DIR/parsing.wasm
cp "$(go env GOROOT)/misc/wasm/wasm_exec.js" $PUBLIC_DIR/go_wasm_exec.js

View File

@ -1,4 +1,4 @@
// +build js //go:build js
package main package main
@ -12,6 +12,9 @@ func main() {
js.Global().Set("parseMarkdown", js.FuncOf(func(this js.Value, args []js.Value) interface{} { js.Global().Set("parseMarkdown", js.FuncOf(func(this js.Value, args []js.Value) interface{} {
return parsing.ParseMarkdown(args[0].String(), parsing.ForumPreviewMarkdown) return parsing.ParseMarkdown(args[0].String(), parsing.ForumPreviewMarkdown)
})) }))
js.Global().Set("parseMarkdownEdu", js.FuncOf(func(this js.Value, args []js.Value) interface{} {
return parsing.ParseMarkdown(args[0].String(), parsing.EducationPreviewMarkdown)
}))
var done chan bool var done chan bool
<-done // block forever <-done // block forever

View File

@ -97,6 +97,7 @@
previewWorker.postMessage({ previewWorker.postMessage({
elementID: inputEl.id, elementID: inputEl.id,
markdown: inputEl.value, markdown: inputEl.value,
parserName: '{{ or .ParserName "parseMarkdown" }}',
}); });
} }

View File

@ -1,5 +1,8 @@
importScripts('/public/go_wasm_exec.js'); importScripts('/public/go_wasm_exec.js');
// wowee good javascript yeah
const global = Function('return this')();
/* /*
NOTE(ben): The structure here is a little funny but allows for some debouncing. Any postMessages NOTE(ben): The structure here is a little funny but allows for some debouncing. Any postMessages
that got queued up can run all at once, then it can process the latest one. that got queued up can run all at once, then it can process the latest one.
@ -9,8 +12,8 @@ let wasmLoaded = false;
let jobs = {}; let jobs = {};
onmessage = ({ data }) => { onmessage = ({ data }) => {
const { elementID, markdown } = data; const { elementID, markdown, parserName } = data;
jobs[elementID] = markdown; jobs[elementID] = { markdown, parserName };
setTimeout(doPreview, 0); setTimeout(doPreview, 0);
} }
@ -27,8 +30,8 @@ const doPreview = () => {
return; return;
} }
for (const [elementID, markdown] of Object.entries(jobs)) { for (const [elementID, { markdown, parserName }] of Object.entries(jobs)) {
const html = parseMarkdown(markdown); const html = global[parserName](markdown);
postMessage({ postMessage({
elementID: elementID, elementID: elementID,
html: html, html: html,

View File

@ -311,6 +311,8 @@ func getEditorDataForEduArticle(
MaxFileSize: AssetMaxSize(currentUser), MaxFileSize: AssetMaxSize(currentUser),
UploadUrl: urlContext.BuildAssetUpload(), UploadUrl: urlContext.BuildAssetUpload(),
ShowEduOptions: true, ShowEduOptions: true,
ParserName: "parseMarkdownEdu",
} }
if article != nil { if article != nil {

View File

@ -50,6 +50,7 @@ type editorData struct {
PostReplyingTo *templates.Post PostReplyingTo *templates.Post
ShowEduOptions bool ShowEduOptions bool
ParserName string
MaxFileSize int MaxFileSize int
UploadUrl string UploadUrl string
} }