Initial version of education content (#90)
Co-authored-by: Ben Visness <bvisness@gmail.com> Reviewed-on: hmn/hmn#90
This commit is contained in:
		
							parent
							
								
									42e1ed95fb
								
							
						
					
					
						commit
						d2b34cb87d
					
				| 
						 | 
				
			
			@ -2,47 +2,18 @@
 | 
			
		|||
// Use of this source code is governed by a BSD-style
 | 
			
		||||
// 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 err = new Error("not implemented");
 | 
			
		||||
		err.code = "ENOSYS";
 | 
			
		||||
		return err;
 | 
			
		||||
	};
 | 
			
		||||
 | 
			
		||||
	if (!global.fs) {
 | 
			
		||||
	if (!globalThis.fs) {
 | 
			
		||||
		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
 | 
			
		||||
			writeSync(fd, buf) {
 | 
			
		||||
				outputBuf += decoder.decode(buf);
 | 
			
		||||
| 
						 | 
				
			
			@ -87,8 +58,8 @@
 | 
			
		|||
		};
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if (!global.process) {
 | 
			
		||||
		global.process = {
 | 
			
		||||
	if (!globalThis.process) {
 | 
			
		||||
		globalThis.process = {
 | 
			
		||||
			getuid() { return -1; },
 | 
			
		||||
			getgid() { return -1; },
 | 
			
		||||
			geteuid() { return -1; },
 | 
			
		||||
| 
						 | 
				
			
			@ -102,47 +73,26 @@
 | 
			
		|||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if (!global.crypto && global.require) {
 | 
			
		||||
		const nodeCrypto = require("crypto");
 | 
			
		||||
		global.crypto = {
 | 
			
		||||
			getRandomValues(b) {
 | 
			
		||||
				nodeCrypto.randomFillSync(b);
 | 
			
		||||
			},
 | 
			
		||||
		};
 | 
			
		||||
	}
 | 
			
		||||
	if (!global.crypto) {
 | 
			
		||||
		throw new Error("global.crypto is not available, polyfill required (getRandomValues only)");
 | 
			
		||||
	if (!globalThis.crypto) {
 | 
			
		||||
		throw new Error("globalThis.crypto is not available, polyfill required (crypto.getRandomValues only)");
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if (!global.performance) {
 | 
			
		||||
		global.performance = {
 | 
			
		||||
			now() {
 | 
			
		||||
				const [sec, nsec] = process.hrtime();
 | 
			
		||||
				return sec * 1000 + nsec / 1000000;
 | 
			
		||||
			},
 | 
			
		||||
		};
 | 
			
		||||
	if (!globalThis.performance) {
 | 
			
		||||
		throw new Error("globalThis.performance is not available, polyfill required (performance.now only)");
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if (!global.TextEncoder && global.require) {
 | 
			
		||||
		global.TextEncoder = require("util").TextEncoder;
 | 
			
		||||
	}
 | 
			
		||||
	if (!global.TextEncoder) {
 | 
			
		||||
		throw new Error("global.TextEncoder is not available, polyfill required");
 | 
			
		||||
	if (!globalThis.TextEncoder) {
 | 
			
		||||
		throw new Error("globalThis.TextEncoder is not available, polyfill required");
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if (!global.TextDecoder && global.require) {
 | 
			
		||||
		global.TextDecoder = require("util").TextDecoder;
 | 
			
		||||
	if (!globalThis.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 decoder = new TextDecoder("utf-8");
 | 
			
		||||
 | 
			
		||||
	global.Go = class {
 | 
			
		||||
	globalThis.Go = class {
 | 
			
		||||
		constructor() {
 | 
			
		||||
			this.argv = ["js"];
 | 
			
		||||
			this.env = {};
 | 
			
		||||
| 
						 | 
				
			
			@ -296,8 +246,8 @@
 | 
			
		|||
						setInt64(sp + 8, (timeOrigin + performance.now()) * 1000000);
 | 
			
		||||
					},
 | 
			
		||||
 | 
			
		||||
					// func walltime1() (sec int64, nsec int32)
 | 
			
		||||
					"runtime.walltime1": (sp) => {
 | 
			
		||||
					// func walltime() (sec int64, nsec int32)
 | 
			
		||||
					"runtime.walltime": (sp) => {
 | 
			
		||||
						sp >>>= 0;
 | 
			
		||||
						const msec = (new Date).getTime();
 | 
			
		||||
						setInt64(sp + 8, msec / 1000);
 | 
			
		||||
| 
						 | 
				
			
			@ -401,6 +351,7 @@
 | 
			
		|||
							storeValue(sp + 56, result);
 | 
			
		||||
							this.mem.setUint8(sp + 64, 1);
 | 
			
		||||
						} catch (err) {
 | 
			
		||||
							sp = this._inst.exports.getsp() >>> 0; // see comment above
 | 
			
		||||
							storeValue(sp + 56, err);
 | 
			
		||||
							this.mem.setUint8(sp + 64, 0);
 | 
			
		||||
						}
 | 
			
		||||
| 
						 | 
				
			
			@ -417,6 +368,7 @@
 | 
			
		|||
							storeValue(sp + 40, result);
 | 
			
		||||
							this.mem.setUint8(sp + 48, 1);
 | 
			
		||||
						} catch (err) {
 | 
			
		||||
							sp = this._inst.exports.getsp() >>> 0; // see comment above
 | 
			
		||||
							storeValue(sp + 40, err);
 | 
			
		||||
							this.mem.setUint8(sp + 48, 0);
 | 
			
		||||
						}
 | 
			
		||||
| 
						 | 
				
			
			@ -433,6 +385,7 @@
 | 
			
		|||
							storeValue(sp + 40, result);
 | 
			
		||||
							this.mem.setUint8(sp + 48, 1);
 | 
			
		||||
						} catch (err) {
 | 
			
		||||
							sp = this._inst.exports.getsp() >>> 0; // see comment above
 | 
			
		||||
							storeValue(sp + 40, err);
 | 
			
		||||
							this.mem.setUint8(sp + 48, 0);
 | 
			
		||||
						}
 | 
			
		||||
| 
						 | 
				
			
			@ -514,7 +467,7 @@
 | 
			
		|||
				null,
 | 
			
		||||
				true,
 | 
			
		||||
				false,
 | 
			
		||||
				global,
 | 
			
		||||
				globalThis,
 | 
			
		||||
				this,
 | 
			
		||||
			];
 | 
			
		||||
			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],
 | 
			
		||||
				[true, 3],
 | 
			
		||||
				[false, 4],
 | 
			
		||||
				[global, 5],
 | 
			
		||||
				[globalThis, 5],
 | 
			
		||||
				[this, 6],
 | 
			
		||||
			]);
 | 
			
		||||
			this._idPool = [];   // unused ids that have been garbage collected
 | 
			
		||||
| 
						 | 
				
			
			@ -564,6 +517,13 @@
 | 
			
		|||
				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);
 | 
			
		||||
			if (this.exited) {
 | 
			
		||||
				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.
										
									
								
							| 
						 | 
				
			
			@ -1176,7 +1176,7 @@ img, video {
 | 
			
		|||
.br2, .post-content code, .post-content pre > code, .post-content pre.hmn-code {
 | 
			
		||||
  border-radius: 0.25rem; }
 | 
			
		||||
 | 
			
		||||
.br3 {
 | 
			
		||||
.br3, .edu-article .edu-resource {
 | 
			
		||||
  border-radius: 0.5rem; }
 | 
			
		||||
 | 
			
		||||
.br4 {
 | 
			
		||||
| 
						 | 
				
			
			@ -4602,7 +4602,7 @@ code, .code {
 | 
			
		|||
.pa2, .tab, header .root-item > a, header .submenu > a {
 | 
			
		||||
  padding: 0.5rem; }
 | 
			
		||||
 | 
			
		||||
.pa3, header #login-popup {
 | 
			
		||||
.pa3, .edu-article .edu-resource, header #login-popup {
 | 
			
		||||
  padding: 1rem; }
 | 
			
		||||
 | 
			
		||||
.pa4 {
 | 
			
		||||
| 
						 | 
				
			
			@ -7369,6 +7369,11 @@ article code {
 | 
			
		|||
    flex-grow: 1;
 | 
			
		||||
    flex-shrink: 1; } }
 | 
			
		||||
 | 
			
		||||
.c--inherit {
 | 
			
		||||
  color: inherit; }
 | 
			
		||||
  .c--inherit:hover, .c--inherit:active {
 | 
			
		||||
    color: inherit; }
 | 
			
		||||
 | 
			
		||||
.b--theme {
 | 
			
		||||
  border-color: #666;
 | 
			
		||||
  border-color: var(--theme-color); }
 | 
			
		||||
| 
						 | 
				
			
			@ -7421,7 +7426,7 @@ article code {
 | 
			
		|||
  border-color: #ccc;
 | 
			
		||||
  border-color: var(--theme-color-dimmest); }
 | 
			
		||||
 | 
			
		||||
.bg--dim, .post-content code, .post-content pre > code, .post-content pre.hmn-code {
 | 
			
		||||
.bg--dim, .post-content code, .post-content pre > code, .post-content pre.hmn-code, .edu-article .edu-resource {
 | 
			
		||||
  background-color: #f0f0f0;
 | 
			
		||||
  background-color: var(--dim-background); }
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -8296,6 +8301,9 @@ nav.timecodes {
 | 
			
		|||
  text-align: center;
 | 
			
		||||
  margin: 10px 0; }
 | 
			
		||||
 | 
			
		||||
.edu-article .note {
 | 
			
		||||
  color: red; }
 | 
			
		||||
 | 
			
		||||
form {
 | 
			
		||||
  margin: 0; }
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										12
									
								
								src/db/db.go
								
								
								
								
							
							
						
						
									
										12
									
								
								src/db/db.go
								
								
								
								
							| 
						 | 
				
			
			@ -158,8 +158,12 @@ func QueryOne[T any](
 | 
			
		|||
 | 
			
		||||
	result, hasRow := rows.Next()
 | 
			
		||||
	if !hasRow {
 | 
			
		||||
		if readErr := rows.Err(); readErr != nil {
 | 
			
		||||
			return nil, readErr
 | 
			
		||||
		} else {
 | 
			
		||||
			return nil, NotFound
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return result, nil
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -244,8 +248,12 @@ func QueryOneScalar[T any](
 | 
			
		|||
	result, hasRow := rows.Next()
 | 
			
		||||
	if !hasRow {
 | 
			
		||||
		var zero T
 | 
			
		||||
		if readErr := rows.Err(); readErr != nil {
 | 
			
		||||
			return zero, readErr
 | 
			
		||||
		} else {
 | 
			
		||||
			return zero, NotFound
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return *result, nil
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -585,6 +593,10 @@ func (it *Iterator[T]) Next() (*T, bool) {
 | 
			
		|||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (it *Iterator[T]) Err() error {
 | 
			
		||||
	return it.rows.Err()
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Takes a value from a database query (reflected) and assigns it to the
 | 
			
		||||
// destination. If the destination is a pointer, and the value is non-nil, it
 | 
			
		||||
// will initialize the destination before assigning.
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -29,7 +29,7 @@ func StartServer(ctx context.Context) jobs.Job {
 | 
			
		|||
		return jobs.Noop()
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	utils.Must0(os.MkdirAll(dir, fs.ModePerm))
 | 
			
		||||
	utils.Must(os.MkdirAll(dir, fs.ModePerm))
 | 
			
		||||
 | 
			
		||||
	s := server{
 | 
			
		||||
		log: logging.ExtractLogger(ctx).With().
 | 
			
		||||
| 
						 | 
				
			
			@ -84,7 +84,7 @@ func (s *server) putObject(w http.ResponseWriter, r *http.Request) {
 | 
			
		|||
	bucket, key := bucketKey(r)
 | 
			
		||||
 | 
			
		||||
	w.Header().Set("Location", fmt.Sprintf("/%s", bucket))
 | 
			
		||||
	utils.Must0(os.MkdirAll(filepath.Join(dir, bucket), fs.ModePerm))
 | 
			
		||||
	utils.Must(os.MkdirAll(filepath.Join(dir, bucket), fs.ModePerm))
 | 
			
		||||
	if key != "" {
 | 
			
		||||
		file := utils.Must1(os.Create(filepath.Join(dir, bucket, key)))
 | 
			
		||||
		io.Copy(file, r.Body)
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -28,13 +28,13 @@ func QFromURL(u *url.URL) []Q {
 | 
			
		|||
}
 | 
			
		||||
 | 
			
		||||
var baseUrlParsed url.URL
 | 
			
		||||
var cacheBust string
 | 
			
		||||
var cacheBustVersion string
 | 
			
		||||
var S3BaseUrl string
 | 
			
		||||
var isTest bool
 | 
			
		||||
 | 
			
		||||
func init() {
 | 
			
		||||
	SetGlobalBaseUrl(config.Config.BaseUrl)
 | 
			
		||||
	SetCacheBust(fmt.Sprint(time.Now().Unix()))
 | 
			
		||||
	SetCacheBustVersion(fmt.Sprint(time.Now().Unix()))
 | 
			
		||||
	SetS3BaseUrl(config.Config.DigitalOcean.AssetsPublicUrlRoot)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -50,8 +50,8 @@ func SetGlobalBaseUrl(fullBaseUrl string) {
 | 
			
		|||
	baseUrlParsed = *parsed
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func SetCacheBust(newCacheBust string) {
 | 
			
		||||
	cacheBust = newCacheBust
 | 
			
		||||
func SetCacheBustVersion(newCacheBustVersion string) {
 | 
			
		||||
	cacheBustVersion = newCacheBustVersion
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func SetS3BaseUrl(base string) {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -75,7 +75,7 @@ func TestLogoutAction(t *testing.T) {
 | 
			
		|||
}
 | 
			
		||||
 | 
			
		||||
func TestRegister(t *testing.T) {
 | 
			
		||||
	AssertRegexMatch(t, BuildRegister(), RegexRegister, nil)
 | 
			
		||||
	AssertRegexMatch(t, BuildRegister(""), RegexRegister, nil)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func TestRegistrationSuccess(t *testing.T) {
 | 
			
		||||
| 
						 | 
				
			
			@ -83,7 +83,7 @@ func TestRegistrationSuccess(t *testing.T) {
 | 
			
		|||
}
 | 
			
		||||
 | 
			
		||||
func TestEmailConfirmation(t *testing.T) {
 | 
			
		||||
	AssertRegexMatch(t, BuildEmailConfirmation("mruser", "test_token"), RegexEmailConfirmation, map[string]string{"username": "mruser", "token": "test_token"})
 | 
			
		||||
	AssertRegexMatch(t, BuildEmailConfirmation("mruser", "test_token", ""), RegexEmailConfirmation, map[string]string{"username": "mruser", "token": "test_token"})
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func TestPasswordReset(t *testing.T) {
 | 
			
		||||
| 
						 | 
				
			
			@ -185,6 +185,32 @@ func TestFishbowl(t *testing.T) {
 | 
			
		|||
	AssertRegexNoMatch(t, BuildFishbowl("oop")+"/otherfiles/whatever", RegexFishbowl)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func TestEducationIndex(t *testing.T) {
 | 
			
		||||
	AssertRegexMatch(t, BuildEducationIndex(), RegexEducationIndex, nil)
 | 
			
		||||
	AssertRegexNoMatch(t, BuildEducationArticle("foo"), RegexEducationIndex)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func TestEducationGlossary(t *testing.T) {
 | 
			
		||||
	AssertRegexMatch(t, BuildEducationGlossary(""), RegexEducationGlossary, map[string]string{"slug": ""})
 | 
			
		||||
	AssertRegexMatch(t, BuildEducationGlossary("foo"), RegexEducationGlossary, map[string]string{"slug": "foo"})
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func TestEducationArticle(t *testing.T) {
 | 
			
		||||
	AssertRegexMatch(t, BuildEducationArticle("foo"), RegexEducationArticle, map[string]string{"slug": "foo"})
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func TestEducationArticleNew(t *testing.T) {
 | 
			
		||||
	AssertRegexMatch(t, BuildEducationArticleNew(), RegexEducationArticleNew, nil)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func TestEducationArticleEdit(t *testing.T) {
 | 
			
		||||
	AssertRegexMatch(t, BuildEducationArticleEdit("foo"), RegexEducationArticleEdit, map[string]string{"slug": "foo"})
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func TestEducationArticleDelete(t *testing.T) {
 | 
			
		||||
	AssertRegexMatch(t, BuildEducationArticleDelete("foo"), RegexEducationArticleDelete, map[string]string{"slug": "foo"})
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func TestForum(t *testing.T) {
 | 
			
		||||
	AssertRegexMatch(t, hmn.BuildForum(nil, 1), RegexForum, nil)
 | 
			
		||||
	AssertRegexMatch(t, hmn.BuildForum([]string{"wip"}, 2), RegexForum, map[string]string{"subforums": "wip", "page": "2"})
 | 
			
		||||
| 
						 | 
				
			
			@ -282,22 +308,6 @@ func TestBlogPostReply(t *testing.T) {
 | 
			
		|||
	AssertSubdomain(t, hero.BuildBlogPostReply(1, 2), "hero")
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func TestLibrary(t *testing.T) {
 | 
			
		||||
	AssertRegexMatch(t, BuildLibrary(), RegexLibrary, nil)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func TestLibraryAll(t *testing.T) {
 | 
			
		||||
	AssertRegexMatch(t, BuildLibraryAll(), RegexLibraryAll, nil)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func TestLibraryTopic(t *testing.T) {
 | 
			
		||||
	AssertRegexMatch(t, BuildLibraryTopic(1), RegexLibraryTopic, map[string]string{"topicid": "1"})
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func TestLibraryResource(t *testing.T) {
 | 
			
		||||
	AssertRegexMatch(t, BuildLibraryResource(1), RegexLibraryResource, map[string]string{"resourceid": "1"})
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func TestEpisodeGuide(t *testing.T) {
 | 
			
		||||
	AssertRegexMatch(t, hero.BuildEpisodeList(""), RegexEpisodeList, map[string]string{"topic": ""})
 | 
			
		||||
	AssertRegexMatch(t, hero.BuildEpisodeList("code"), RegexEpisodeList, map[string]string{"topic": "code"})
 | 
			
		||||
| 
						 | 
				
			
			@ -360,6 +370,26 @@ func TestJamIndex(t *testing.T) {
 | 
			
		|||
	AssertSubdomain(t, BuildJamIndex(), "")
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func TestJamIndex2021(t *testing.T) {
 | 
			
		||||
	AssertRegexMatch(t, BuildJamIndex2021(), RegexJamIndex2021, nil)
 | 
			
		||||
	AssertSubdomain(t, BuildJamIndex2021(), "")
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func TestJamIndex2022(t *testing.T) {
 | 
			
		||||
	AssertRegexMatch(t, BuildJamIndex2022(), RegexJamIndex2022, nil)
 | 
			
		||||
	AssertSubdomain(t, BuildJamIndex2022(), "")
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func TestJamFeed2022(t *testing.T) {
 | 
			
		||||
	AssertRegexMatch(t, BuildJamFeed2022(), RegexJamFeed2022, nil)
 | 
			
		||||
	AssertSubdomain(t, BuildJamFeed2022(), "")
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func TestProjectNewJam(t *testing.T) {
 | 
			
		||||
	AssertRegexMatch(t, BuildProjectNewJam(), RegexProjectNew, nil)
 | 
			
		||||
	AssertSubdomain(t, BuildProjectNewJam(), "")
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func TestDiscordOAuthCallback(t *testing.T) {
 | 
			
		||||
	AssertRegexMatch(t, BuildDiscordOAuthCallback(), RegexDiscordOAuthCallback, nil)
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -434,6 +434,53 @@ func BuildFishbowl(slug string) string {
 | 
			
		|||
 | 
			
		||||
var RegexFishbowlFiles = regexp.MustCompile(`^/fishbowl/(?P<slug>[^/]+)(?P<path>/.+)$`)
 | 
			
		||||
 | 
			
		||||
/*
 | 
			
		||||
 * Education
 | 
			
		||||
 */
 | 
			
		||||
 | 
			
		||||
var RegexEducationIndex = regexp.MustCompile(`^/education$`)
 | 
			
		||||
 | 
			
		||||
func BuildEducationIndex() string {
 | 
			
		||||
	defer CatchPanic()
 | 
			
		||||
	return Url("/education", nil)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
var RegexEducationGlossary = regexp.MustCompile(`^/education/glossary(/(?P<slug>[^/]+))?$`)
 | 
			
		||||
 | 
			
		||||
func BuildEducationGlossary(termSlug string) string {
 | 
			
		||||
	defer CatchPanic()
 | 
			
		||||
 | 
			
		||||
	if termSlug == "" {
 | 
			
		||||
		return Url("/education/glossary", nil)
 | 
			
		||||
	} else {
 | 
			
		||||
		return Url(fmt.Sprintf("/education/glossary/%s", termSlug), nil)
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
var RegexEducationArticle = regexp.MustCompile(`^/education/(?P<slug>[^/]+)$`)
 | 
			
		||||
 | 
			
		||||
func BuildEducationArticle(slug string) string {
 | 
			
		||||
	return Url(fmt.Sprintf("/education/%s", slug), nil)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
var RegexEducationArticleNew = regexp.MustCompile(`^/education/new$`)
 | 
			
		||||
 | 
			
		||||
func BuildEducationArticleNew() string {
 | 
			
		||||
	return Url("/education/new", nil)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
var RegexEducationArticleEdit = regexp.MustCompile(`^/education/(?P<slug>[^/]+)/edit$`)
 | 
			
		||||
 | 
			
		||||
func BuildEducationArticleEdit(slug string) string {
 | 
			
		||||
	return Url(fmt.Sprintf("/education/%s/edit", slug), nil)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
var RegexEducationArticleDelete = regexp.MustCompile(`^/education/(?P<slug>[^/]+)/delete$`)
 | 
			
		||||
 | 
			
		||||
func BuildEducationArticleDelete(slug string) string {
 | 
			
		||||
	return Url(fmt.Sprintf("/education/%s/delete", slug), nil)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/*
 | 
			
		||||
* Forums
 | 
			
		||||
 */
 | 
			
		||||
| 
						 | 
				
			
			@ -651,47 +698,8 @@ func (c *UrlContext) BuildBlogPostReply(threadId int, postId int) string {
 | 
			
		|||
* Library
 | 
			
		||||
 */
 | 
			
		||||
 | 
			
		||||
// Any library route. Remove after we port the library.
 | 
			
		||||
var RegexLibraryAny = regexp.MustCompile(`^/library`)
 | 
			
		||||
 | 
			
		||||
var RegexLibrary = regexp.MustCompile(`^/library$`)
 | 
			
		||||
 | 
			
		||||
func BuildLibrary() string {
 | 
			
		||||
	defer CatchPanic()
 | 
			
		||||
	return Url("/library", nil)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
var RegexLibraryAll = regexp.MustCompile(`^/library/all$`)
 | 
			
		||||
 | 
			
		||||
func BuildLibraryAll() string {
 | 
			
		||||
	defer CatchPanic()
 | 
			
		||||
	return Url("/library/all", nil)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
var RegexLibraryTopic = regexp.MustCompile(`^/library/topic/(?P<topicid>\d+)$`)
 | 
			
		||||
 | 
			
		||||
func BuildLibraryTopic(topicId int) string {
 | 
			
		||||
	defer CatchPanic()
 | 
			
		||||
	if topicId < 1 {
 | 
			
		||||
		panic(oops.New(nil, "Invalid library topic ID (%d), must be >= 1", topicId))
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	var builder strings.Builder
 | 
			
		||||
	builder.WriteString("/library/topic/")
 | 
			
		||||
	builder.WriteString(strconv.Itoa(topicId))
 | 
			
		||||
 | 
			
		||||
	return Url(builder.String(), nil)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
var RegexLibraryResource = regexp.MustCompile(`^/library/resource/(?P<resourceid>\d+)$`)
 | 
			
		||||
 | 
			
		||||
func BuildLibraryResource(resourceId int) string {
 | 
			
		||||
	defer CatchPanic()
 | 
			
		||||
	builder := buildLibraryResourcePath(resourceId)
 | 
			
		||||
 | 
			
		||||
	return Url(builder.String(), nil)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/*
 | 
			
		||||
* Episode Guide
 | 
			
		||||
 */
 | 
			
		||||
| 
						 | 
				
			
			@ -829,7 +837,7 @@ func BuildPublic(filepath string, cachebust bool) string {
 | 
			
		|||
	}
 | 
			
		||||
	var query []Q
 | 
			
		||||
	if cachebust {
 | 
			
		||||
		query = []Q{{"v", cacheBust}}
 | 
			
		||||
		query = []Q{{"v", cacheBustVersion}}
 | 
			
		||||
	}
 | 
			
		||||
	return Url(builder.String(), query)
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -52,6 +52,14 @@ func init() {
 | 
			
		|||
	}
 | 
			
		||||
	migrateCommand.Flags().BoolVar(&listMigrations, "list", false, "List available migrations")
 | 
			
		||||
 | 
			
		||||
	rollbackCommand := &cobra.Command{
 | 
			
		||||
		Use:   "rollback",
 | 
			
		||||
		Short: "Roll back the most recent completed migration",
 | 
			
		||||
		Run: func(cmd *cobra.Command, args []string) {
 | 
			
		||||
			Rollback()
 | 
			
		||||
		},
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	makeMigrationCommand := &cobra.Command{
 | 
			
		||||
		Use:   "makemigration <name> <description>...",
 | 
			
		||||
		Short: "Create a new database migration file",
 | 
			
		||||
| 
						 | 
				
			
			@ -95,6 +103,7 @@ func init() {
 | 
			
		|||
 | 
			
		||||
	website.WebsiteCommand.AddCommand(dbCommand)
 | 
			
		||||
	dbCommand.AddCommand(migrateCommand)
 | 
			
		||||
	dbCommand.AddCommand(rollbackCommand)
 | 
			
		||||
	dbCommand.AddCommand(makeMigrationCommand)
 | 
			
		||||
	dbCommand.AddCommand(seedCommand)
 | 
			
		||||
	dbCommand.AddCommand(seedFromFileCommand)
 | 
			
		||||
| 
						 | 
				
			
			@ -126,7 +135,7 @@ func getCurrentVersion(ctx context.Context, conn *pgx.Conn) (types.MigrationVers
 | 
			
		|||
 | 
			
		||||
func tryGetCurrentVersion(ctx context.Context) types.MigrationVersion {
 | 
			
		||||
	defer func() {
 | 
			
		||||
		recover()
 | 
			
		||||
		recover() // NOTE(ben): wat
 | 
			
		||||
	}()
 | 
			
		||||
 | 
			
		||||
	conn := db.NewConn()
 | 
			
		||||
| 
						 | 
				
			
			@ -267,8 +276,8 @@ func Migrate(targetVersion types.MigrationVersion) {
 | 
			
		|||
			}
 | 
			
		||||
			defer tx.Rollback(ctx)
 | 
			
		||||
 | 
			
		||||
			fmt.Printf("Rolling back migration %v\n", version)
 | 
			
		||||
			migration := migrations.All[version]
 | 
			
		||||
			fmt.Printf("Rolling back migration %v (%s)\n", migration.Version(), migration.Name())
 | 
			
		||||
			err = migration.Down(ctx, tx)
 | 
			
		||||
			if err != nil {
 | 
			
		||||
				fmt.Printf("MIGRATION FAILED for migration %v.\n", version)
 | 
			
		||||
| 
						 | 
				
			
			@ -291,6 +300,39 @@ func Migrate(targetVersion types.MigrationVersion) {
 | 
			
		|||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func Rollback() {
 | 
			
		||||
	ctx := context.Background()
 | 
			
		||||
 | 
			
		||||
	conn := db.NewConnWithConfig(config.PostgresConfig{
 | 
			
		||||
		LogLevel: pgx.LogLevelWarn,
 | 
			
		||||
	})
 | 
			
		||||
	defer conn.Close(ctx)
 | 
			
		||||
 | 
			
		||||
	currentVersion := tryGetCurrentVersion(ctx)
 | 
			
		||||
	if currentVersion.IsZero() {
 | 
			
		||||
		fmt.Println("You have never run migrations; nothing to do.")
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	var target types.MigrationVersion
 | 
			
		||||
	versions := getSortedMigrationVersions()
 | 
			
		||||
	for i := 1; i < len(versions); i++ {
 | 
			
		||||
		if versions[i].Equal(currentVersion) {
 | 
			
		||||
			target = versions[i-1]
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// NOTE(ben): It occurs to me that we don't have a way to roll back the initial migration, ever.
 | 
			
		||||
	// Not that we would ever want to....?
 | 
			
		||||
 | 
			
		||||
	if target.IsZero() {
 | 
			
		||||
		fmt.Println("You are already at the earliest migration; nothing to do.")
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	Migrate(target)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
//go:embed migrationTemplate.txt
 | 
			
		||||
var migrationTemplate string
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -323,14 +365,13 @@ func ResetDB() {
 | 
			
		|||
 | 
			
		||||
	// Create the HMN database user
 | 
			
		||||
	{
 | 
			
		||||
		type pgCredentials struct {
 | 
			
		||||
			User     string
 | 
			
		||||
			Password string
 | 
			
		||||
		}
 | 
			
		||||
		credentials := []pgCredentials{
 | 
			
		||||
			{config.Config.Postgres.User, config.Config.Postgres.Password}, // Existing HMN user
 | 
			
		||||
			{getSystemUsername(), ""},                                      // Postgres.app on Mac
 | 
			
		||||
		}
 | 
			
		||||
		credentials := append(
 | 
			
		||||
			[]pgCredentials{
 | 
			
		||||
				{config.Config.Postgres.User, config.Config.Postgres.Password, false}, // Existing HMN user
 | 
			
		||||
				{getSystemUsername(), "", true},                                       // Postgres.app on Mac
 | 
			
		||||
			},
 | 
			
		||||
			guessCredentials()...,
 | 
			
		||||
		)
 | 
			
		||||
 | 
			
		||||
		var workingCred pgCredentials
 | 
			
		||||
		var createUserConn *pgconn.PgConn
 | 
			
		||||
| 
						 | 
				
			
			@ -341,6 +382,9 @@ func ResetDB() {
 | 
			
		|||
			createUserConn, err = connectLowLevel(ctx, cred.User, cred.Password)
 | 
			
		||||
			if err == nil {
 | 
			
		||||
				workingCred = cred
 | 
			
		||||
				if cred.SafeToPrint {
 | 
			
		||||
					fmt.Printf("Connected by guessing username \"%s\" and password \"%s\".\n", cred.User, cred.Password)
 | 
			
		||||
				}
 | 
			
		||||
				break
 | 
			
		||||
			} else {
 | 
			
		||||
				connErrors = append(connErrors, err)
 | 
			
		||||
| 
						 | 
				
			
			@ -448,3 +492,22 @@ func getSystemUsername() string {
 | 
			
		|||
	}
 | 
			
		||||
	return u.Username
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type pgCredentials struct {
 | 
			
		||||
	User        string
 | 
			
		||||
	Password    string
 | 
			
		||||
	SafeToPrint bool
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
var commonRootUsernames = []string{getSystemUsername(), "postgres", "root"}
 | 
			
		||||
var commonRootPasswords = []string{"", "password", "postgres"}
 | 
			
		||||
 | 
			
		||||
func guessCredentials() []pgCredentials {
 | 
			
		||||
	var result []pgCredentials
 | 
			
		||||
	for _, username := range commonRootUsernames {
 | 
			
		||||
		for _, password := range commonRootPasswords {
 | 
			
		||||
			result = append(result, pgCredentials{username, password, true})
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
	return result
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -0,0 +1,92 @@
 | 
			
		|||
package migrations
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"context"
 | 
			
		||||
	"time"
 | 
			
		||||
 | 
			
		||||
	"git.handmade.network/hmn/hmn/src/migration/types"
 | 
			
		||||
	"git.handmade.network/hmn/hmn/src/oops"
 | 
			
		||||
	"github.com/jackc/pgx/v4"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
func init() {
 | 
			
		||||
	registerMigration(AddEducationResources{})
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type AddEducationResources struct{}
 | 
			
		||||
 | 
			
		||||
func (m AddEducationResources) Version() types.MigrationVersion {
 | 
			
		||||
	return types.MigrationVersion(time.Date(2022, 9, 10, 0, 0, 0, 0, time.UTC))
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (m AddEducationResources) Name() string {
 | 
			
		||||
	return "AddEducationResources"
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (m AddEducationResources) Description() string {
 | 
			
		||||
	return "Adds the tables needed for the 2022 education initiative"
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (m AddEducationResources) Up(ctx context.Context, tx pgx.Tx) error {
 | 
			
		||||
	_, err := tx.Exec(ctx,
 | 
			
		||||
		`
 | 
			
		||||
		CREATE TABLE education_article_version (
 | 
			
		||||
			id SERIAL NOT NULL PRIMARY KEY
 | 
			
		||||
		);
 | 
			
		||||
 | 
			
		||||
		CREATE TABLE education_article (
 | 
			
		||||
			id SERIAL NOT NULL PRIMARY KEY,
 | 
			
		||||
			title VARCHAR(255) NOT NULL,
 | 
			
		||||
			slug VARCHAR(255) NOT NULL UNIQUE,
 | 
			
		||||
			description TEXT NOT NULL,
 | 
			
		||||
			type INT NOT NULL,
 | 
			
		||||
			published BOOLEAN NOT NULL DEFAULT FALSE,
 | 
			
		||||
			current_version INT NOT NULL REFERENCES education_article_version (id) DEFERRABLE INITIALLY DEFERRED
 | 
			
		||||
		);
 | 
			
		||||
 | 
			
		||||
		ALTER TABLE education_article_version
 | 
			
		||||
			ADD article_id INT NOT NULL REFERENCES education_article (id) ON DELETE CASCADE,
 | 
			
		||||
			ADD date TIMESTAMP WITH TIME ZONE NOT NULL,
 | 
			
		||||
			ADD content_raw TEXT NOT NULL,
 | 
			
		||||
			ADD content_html TEXT NOT NULL,
 | 
			
		||||
			ADD editor_id INT REFERENCES hmn_user (id) ON DELETE SET NULL DEFERRABLE INITIALLY DEFERRED;
 | 
			
		||||
		`,
 | 
			
		||||
	)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return oops.New(err, "failed to create education tables")
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	_, err = tx.Exec(ctx,
 | 
			
		||||
		`
 | 
			
		||||
		ALTER TABLE hmn_user
 | 
			
		||||
			DROP edit_library,
 | 
			
		||||
			ADD education_role INT NOT NULL DEFAULT 0;
 | 
			
		||||
		`,
 | 
			
		||||
	)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return oops.New(err, "failed to update user stuff")
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (m AddEducationResources) Down(ctx context.Context, tx pgx.Tx) error {
 | 
			
		||||
	_, err := tx.Exec(ctx, `
 | 
			
		||||
		DROP TABLE education_article CASCADE;
 | 
			
		||||
		DROP TABLE education_article_version CASCADE;
 | 
			
		||||
	`)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return oops.New(err, "failed to delete education tables")
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	_, err = tx.Exec(ctx, `
 | 
			
		||||
		ALTER TABLE hmn_user
 | 
			
		||||
			DROP education_role,
 | 
			
		||||
			ADD edit_library BOOLEAN NOT NULL DEFAULT FALSE;
 | 
			
		||||
	`)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return oops.New(err, "failed to delete education tables")
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return nil
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -64,7 +64,7 @@ func BareMinimumSeed() *models.Project {
 | 
			
		|||
	fmt.Println("Creating HMN project...")
 | 
			
		||||
	hmn := seedProject(ctx, tx, seedHMN, nil)
 | 
			
		||||
 | 
			
		||||
	utils.Must0(tx.Commit(ctx))
 | 
			
		||||
	utils.Must(tx.Commit(ctx))
 | 
			
		||||
 | 
			
		||||
	return hmn
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -166,7 +166,7 @@ func SampleSeed() {
 | 
			
		|||
	// Finally, set sequence numbers to things that won't conflict
 | 
			
		||||
	utils.Must1(tx.Exec(ctx, "SELECT setval('project_id_seq', 100, true);"))
 | 
			
		||||
 | 
			
		||||
	utils.Must0(tx.Commit(ctx))
 | 
			
		||||
	utils.Must(tx.Commit(ctx))
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func seedUser(ctx context.Context, conn db.ConnOrTx, input models.User) *models.User {
 | 
			
		||||
| 
						 | 
				
			
			@ -178,7 +178,7 @@ func seedUser(ctx context.Context, conn db.ConnOrTx, input models.User) *models.
 | 
			
		|||
			status,
 | 
			
		||||
			name, bio, blurb, signature,
 | 
			
		||||
			darktheme,
 | 
			
		||||
			showemail, edit_library,
 | 
			
		||||
			showemail,
 | 
			
		||||
			date_joined, registration_ip, avatar_asset_id
 | 
			
		||||
		)
 | 
			
		||||
		VALUES (
 | 
			
		||||
| 
						 | 
				
			
			@ -187,7 +187,7 @@ func seedUser(ctx context.Context, conn db.ConnOrTx, input models.User) *models.
 | 
			
		|||
			$5,
 | 
			
		||||
			$6, $7, $8, $9,
 | 
			
		||||
			TRUE,
 | 
			
		||||
			$10, FALSE,
 | 
			
		||||
			$10,
 | 
			
		||||
			'2017-01-01T00:00:00Z', '192.168.2.1', null
 | 
			
		||||
		)
 | 
			
		||||
		RETURNING $columns
 | 
			
		||||
| 
						 | 
				
			
			@ -198,7 +198,7 @@ func seedUser(ctx context.Context, conn db.ConnOrTx, input models.User) *models.
 | 
			
		|||
		utils.OrDefault(input.Name, randomName()), utils.OrDefault(input.Bio, lorem.Paragraph(0, 2)), utils.OrDefault(input.Blurb, lorem.Sentence(0, 14)), utils.OrDefault(input.Signature, lorem.Sentence(0, 16)),
 | 
			
		||||
		input.ShowEmail,
 | 
			
		||||
	)
 | 
			
		||||
	utils.Must0(auth.SetPassword(ctx, conn, input.Username, "password"))
 | 
			
		||||
	utils.Must(auth.SetPassword(ctx, conn, input.Username, "password"))
 | 
			
		||||
 | 
			
		||||
	return user
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -0,0 +1,44 @@
 | 
			
		|||
package models
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"time"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
type EduArticle struct {
 | 
			
		||||
	ID int `db:"id"`
 | 
			
		||||
 | 
			
		||||
	Title       string `db:"title"`
 | 
			
		||||
	Slug        string `db:"slug"`
 | 
			
		||||
	Description string `db:"description"`
 | 
			
		||||
	Published   bool   `db:"published"` // Unpublished articles are visible to authors and beta testers.
 | 
			
		||||
 | 
			
		||||
	Type EduArticleType `db:"type"`
 | 
			
		||||
 | 
			
		||||
	CurrentVersionID int                `db:"current_version"`
 | 
			
		||||
	CurrentVersion   *EduArticleVersion // not in DB, set by helpers
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type EduArticleType int
 | 
			
		||||
 | 
			
		||||
const (
 | 
			
		||||
	EduArticleTypeArticle EduArticleType = iota + 1
 | 
			
		||||
	EduArticleTypeGlossary
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
type EduArticleVersion struct {
 | 
			
		||||
	ID        int       `db:"id"`
 | 
			
		||||
	ArticleID int       `db:"article_id"`
 | 
			
		||||
	Date      time.Time `db:"date"`
 | 
			
		||||
	EditorID  *int      `db:"editor_id"`
 | 
			
		||||
 | 
			
		||||
	ContentRaw  string `db:"content_raw"`
 | 
			
		||||
	ContentHTML string `db:"content_html"`
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type EduRole int
 | 
			
		||||
 | 
			
		||||
const (
 | 
			
		||||
	EduRoleNone EduRole = iota
 | 
			
		||||
	EduRoleBeta
 | 
			
		||||
	EduRoleAuthor
 | 
			
		||||
)
 | 
			
		||||
| 
						 | 
				
			
			@ -13,9 +13,9 @@ type UserStatus int
 | 
			
		|||
 | 
			
		||||
const (
 | 
			
		||||
	UserStatusInactive  UserStatus = 1 // Default for new users
 | 
			
		||||
	UserStatusConfirmed            = 2 // Confirmed email address
 | 
			
		||||
	UserStatusApproved             = 3 // Approved by an admin and allowed to publicly post
 | 
			
		||||
	UserStatusBanned               = 4 // BALEETED
 | 
			
		||||
	UserStatusConfirmed UserStatus = 2 // Confirmed email address
 | 
			
		||||
	UserStatusApproved  UserStatus = 3 // Approved by an admin and allowed to publicly post
 | 
			
		||||
	UserStatusBanned    UserStatus = 4 // BALEETED
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
type User struct {
 | 
			
		||||
| 
						 | 
				
			
			@ -30,6 +30,7 @@ type User struct {
 | 
			
		|||
 | 
			
		||||
	IsStaff       bool       `db:"is_staff"`
 | 
			
		||||
	Status        UserStatus `db:"status"`
 | 
			
		||||
	EducationRole EduRole    `db:"education_role"`
 | 
			
		||||
 | 
			
		||||
	Name          string     `db:"name"`
 | 
			
		||||
	Bio           string     `db:"bio"`
 | 
			
		||||
| 
						 | 
				
			
			@ -41,7 +42,6 @@ type User struct {
 | 
			
		|||
	Timezone  string `db:"timezone"`
 | 
			
		||||
 | 
			
		||||
	ShowEmail bool `db:"showemail"`
 | 
			
		||||
	CanEditLibrary bool `db:"edit_library"`
 | 
			
		||||
 | 
			
		||||
	DiscordSaveShowcase                 bool `db:"discord_save_showcase"`
 | 
			
		||||
	DiscordDeleteSnippetOnMessageDelete bool `db:"discord_delete_snippet_on_message_delete"`
 | 
			
		||||
| 
						 | 
				
			
			@ -63,3 +63,11 @@ func (u *User) BestName() string {
 | 
			
		|||
func (u *User) IsActive() bool {
 | 
			
		||||
	return u.Status == UserStatusConfirmed
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (u *User) CanSeeUnpublishedEducationContent() bool {
 | 
			
		||||
	return u.IsStaff || u.EducationRole == EduRoleBeta || u.EducationRole == EduRoleAuthor
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (u *User) CanAuthorEducation() bool {
 | 
			
		||||
	return u.IsStaff || u.EducationRole == EduRoleAuthor
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -96,14 +96,6 @@ func (s embedParser) previewOrLegitEmbed(name string, legitHtml string) string {
 | 
			
		|||
	return legitHtml
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func extract(re *regexp.Regexp, src []byte, subexpName string) []byte {
 | 
			
		||||
	m := re.FindSubmatch(src)
 | 
			
		||||
	if m == nil {
 | 
			
		||||
		return nil
 | 
			
		||||
	}
 | 
			
		||||
	return m[re.SubexpIndex(subexpName)]
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func makeYoutubeEmbed(vid string, preview bool) string {
 | 
			
		||||
	if preview {
 | 
			
		||||
		return `
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -0,0 +1,316 @@
 | 
			
		|||
package parsing
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"bytes"
 | 
			
		||||
	"fmt"
 | 
			
		||||
	"regexp"
 | 
			
		||||
 | 
			
		||||
	"git.handmade.network/hmn/hmn/src/hmnurl"
 | 
			
		||||
	"git.handmade.network/hmn/hmn/src/utils"
 | 
			
		||||
	"github.com/yuin/goldmark"
 | 
			
		||||
	"github.com/yuin/goldmark/ast"
 | 
			
		||||
	gast "github.com/yuin/goldmark/ast"
 | 
			
		||||
	"github.com/yuin/goldmark/parser"
 | 
			
		||||
	"github.com/yuin/goldmark/renderer"
 | 
			
		||||
	"github.com/yuin/goldmark/renderer/html"
 | 
			
		||||
	"github.com/yuin/goldmark/text"
 | 
			
		||||
	"github.com/yuin/goldmark/util"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
// NOTE(ben): ggcode is my cute name for our custom extension syntax because I got fed up with
 | 
			
		||||
// bbcode. It's designed to be a more natural fit for Goldmark's method of parsing, while still
 | 
			
		||||
// being a general-purpose tag-like syntax that's easy for us to add instances of without writing
 | 
			
		||||
// new Goldmark parsers.
 | 
			
		||||
//
 | 
			
		||||
// Inline ggcode is delimited by two exclamation marks. Block ggcode is delimited by three. Inline
 | 
			
		||||
// ggcode uses parentheses to delimit the start and end of the affected content. Block ggcode is
 | 
			
		||||
// like a fenced code block and ends with !!!. ggcode sections can optionally have named string
 | 
			
		||||
// arguments inside braces. Quotes around the value are mandatory.
 | 
			
		||||
//
 | 
			
		||||
// Inline example:
 | 
			
		||||
//
 | 
			
		||||
//     See our article on !!glossary{slug="tcp"}(TCP) for more details.
 | 
			
		||||
//
 | 
			
		||||
// Block example:
 | 
			
		||||
//
 | 
			
		||||
//     !!!resource{name="Beej's Guide to Network Programming" url="https://beej.us/guide/bgnet/html/"}
 | 
			
		||||
//     This is a _fantastic_ resource on network programming, suitable for beginners.
 | 
			
		||||
//     !!!
 | 
			
		||||
//
 | 
			
		||||
 | 
			
		||||
var ggcodeTags = map[string]ggcodeTag{
 | 
			
		||||
	"glossary": {
 | 
			
		||||
		Filter: ggcodeFilterEdu,
 | 
			
		||||
		Renderer: func(c ggcodeRendererContext, n *ggcodeNode, entering bool) error {
 | 
			
		||||
			if entering {
 | 
			
		||||
				term, _ := n.Args["term"]
 | 
			
		||||
				c.W.WriteString(fmt.Sprintf(
 | 
			
		||||
					`<a href="%s" class="glossary-term" data-term="%s">`,
 | 
			
		||||
					hmnurl.BuildEducationGlossary(term),
 | 
			
		||||
					term,
 | 
			
		||||
				))
 | 
			
		||||
			} else {
 | 
			
		||||
				c.W.WriteString("</a>")
 | 
			
		||||
			}
 | 
			
		||||
			return nil
 | 
			
		||||
		},
 | 
			
		||||
	},
 | 
			
		||||
	"resource": {
 | 
			
		||||
		Filter: ggcodeFilterEdu,
 | 
			
		||||
		Renderer: func(c ggcodeRendererContext, n *ggcodeNode, entering bool) error {
 | 
			
		||||
			if entering {
 | 
			
		||||
				c.W.WriteString(`<div class="edu-resource">`)
 | 
			
		||||
				c.W.WriteString(fmt.Sprintf(`  <a href="%s" target="_blank"><h2>%s</h2></a>`, n.Args["url"], utils.OrDefault(n.Args["name"], "[missing `name`]")))
 | 
			
		||||
			} else {
 | 
			
		||||
				c.W.WriteString("</div>")
 | 
			
		||||
			}
 | 
			
		||||
			return nil
 | 
			
		||||
		},
 | 
			
		||||
	},
 | 
			
		||||
	"note": {
 | 
			
		||||
		Filter: ggcodeFilterEdu,
 | 
			
		||||
		Renderer: func(c ggcodeRendererContext, n *ggcodeNode, entering bool) error {
 | 
			
		||||
			if entering {
 | 
			
		||||
				c.W.WriteString(`<span class="note">`)
 | 
			
		||||
			} else {
 | 
			
		||||
				c.W.WriteString(`</span>`)
 | 
			
		||||
			}
 | 
			
		||||
			return nil
 | 
			
		||||
		},
 | 
			
		||||
	},
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// ----------------------
 | 
			
		||||
// Types
 | 
			
		||||
// ----------------------
 | 
			
		||||
 | 
			
		||||
type ggcodeRendererContext struct {
 | 
			
		||||
	W      util.BufWriter
 | 
			
		||||
	Source []byte
 | 
			
		||||
	Opts   MarkdownOptions
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type ggcodeTagFilter func(opts MarkdownOptions) bool
 | 
			
		||||
type ggcodeRenderer func(c ggcodeRendererContext, n *ggcodeNode, entering bool) error
 | 
			
		||||
 | 
			
		||||
type ggcodeTag struct {
 | 
			
		||||
	Filter   ggcodeTagFilter
 | 
			
		||||
	Renderer ggcodeRenderer
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
var ggcodeFilterEdu ggcodeTagFilter = func(opts MarkdownOptions) bool {
 | 
			
		||||
	return opts.Education
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// ----------------------
 | 
			
		||||
// Parsers and delimiters
 | 
			
		||||
// ----------------------
 | 
			
		||||
 | 
			
		||||
var reGGCodeBlockOpen = regexp.MustCompile(`^!!!(?P<name>[a-zA-Z0-9-_]+)(\{(?P<args>.*?)\})?$`)
 | 
			
		||||
var reGGCodeInline = regexp.MustCompile(`^!!(?P<name>[a-zA-Z0-9-_]+)(\{(?P<args>.*?)\})?(\((?P<content>.*?)\))?`)
 | 
			
		||||
var reGGCodeArgs = regexp.MustCompile(`(?P<arg>[a-zA-Z0-9-_]+)="(?P<val>.*?)"`)
 | 
			
		||||
 | 
			
		||||
// Block parser stuff
 | 
			
		||||
 | 
			
		||||
type ggcodeBlockParser struct{}
 | 
			
		||||
 | 
			
		||||
var _ parser.BlockParser = ggcodeBlockParser{}
 | 
			
		||||
 | 
			
		||||
func (s ggcodeBlockParser) Trigger() []byte {
 | 
			
		||||
	return []byte("!")
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (s ggcodeBlockParser) Open(parent ast.Node, reader text.Reader, pc parser.Context) (ast.Node, parser.State) {
 | 
			
		||||
	restOfLine, _ := reader.PeekLine()
 | 
			
		||||
 | 
			
		||||
	if match := extractMap(reGGCodeBlockOpen, bytes.TrimSpace(restOfLine)); match != nil {
 | 
			
		||||
		name := string(match["name"])
 | 
			
		||||
		var args map[string]string
 | 
			
		||||
		if argsMatch := extractAllMap(reGGCodeArgs, match["args"]); argsMatch != nil {
 | 
			
		||||
			args = make(map[string]string)
 | 
			
		||||
			for i := range argsMatch["arg"] {
 | 
			
		||||
				arg := string(argsMatch["arg"][i])
 | 
			
		||||
				val := string(argsMatch["val"][i])
 | 
			
		||||
				args[arg] = val
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		reader.Advance(len(restOfLine))
 | 
			
		||||
		return &ggcodeNode{
 | 
			
		||||
			Name: name,
 | 
			
		||||
			Args: args,
 | 
			
		||||
		}, parser.Continue | parser.HasChildren
 | 
			
		||||
	} else {
 | 
			
		||||
		return nil, parser.NoChildren
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (s ggcodeBlockParser) Continue(node ast.Node, reader text.Reader, pc parser.Context) parser.State {
 | 
			
		||||
	line, _ := reader.PeekLine()
 | 
			
		||||
	if string(bytes.TrimSpace(line)) == "!!!" {
 | 
			
		||||
		reader.Advance(3)
 | 
			
		||||
		return parser.Close
 | 
			
		||||
	}
 | 
			
		||||
	return parser.Continue | parser.HasChildren
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (s ggcodeBlockParser) Close(node ast.Node, reader text.Reader, pc parser.Context) {}
 | 
			
		||||
 | 
			
		||||
func (s ggcodeBlockParser) CanInterruptParagraph() bool {
 | 
			
		||||
	return false
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (s ggcodeBlockParser) CanAcceptIndentedLine() bool {
 | 
			
		||||
	return false
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Inline parser stuff
 | 
			
		||||
 | 
			
		||||
type ggcodeInlineParser struct{}
 | 
			
		||||
 | 
			
		||||
var _ parser.InlineParser = ggcodeInlineParser{}
 | 
			
		||||
 | 
			
		||||
func (s ggcodeInlineParser) Trigger() []byte {
 | 
			
		||||
	return []byte("!()")
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (s ggcodeInlineParser) Parse(parent gast.Node, block text.Reader, pc parser.Context) gast.Node {
 | 
			
		||||
	restOfLine, segment := block.PeekLine() // Gets the rest of the line (starting at the current parser cursor index), and the segment representing the indices in the source text.
 | 
			
		||||
	if match := extractMap(reGGCodeInline, restOfLine); match != nil {
 | 
			
		||||
		name := string(match["name"])
 | 
			
		||||
		var args map[string]string
 | 
			
		||||
		if argsMatch := extractAllMap(reGGCodeArgs, match["args"]); argsMatch != nil {
 | 
			
		||||
			args = make(map[string]string)
 | 
			
		||||
			for i := range argsMatch["arg"] {
 | 
			
		||||
				arg := string(argsMatch["arg"][i])
 | 
			
		||||
				val := string(argsMatch["val"][i])
 | 
			
		||||
				args[arg] = val
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		node := &ggcodeNode{
 | 
			
		||||
			Name: name,
 | 
			
		||||
			Args: args,
 | 
			
		||||
		}
 | 
			
		||||
		contentLength := len(match["content"])
 | 
			
		||||
		if contentLength > 0 {
 | 
			
		||||
			contentSegmentStart := segment.Start + len(match["all"]) - (contentLength + 1) // the 1 is for the end parenthesis
 | 
			
		||||
			contentSegmentEnd := contentSegmentStart + contentLength
 | 
			
		||||
			contentSegment := text.NewSegment(contentSegmentStart, contentSegmentEnd)
 | 
			
		||||
			node.AppendChild(node, ast.NewTextSegment(contentSegment))
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		block.Advance(len(match["all"]))
 | 
			
		||||
		return node
 | 
			
		||||
	} else {
 | 
			
		||||
		return nil
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type ggcodeDelimiterParser struct {
 | 
			
		||||
	Node *ggcodeNode // We need to pass this through 🙄
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (p ggcodeDelimiterParser) IsDelimiter(b byte) bool {
 | 
			
		||||
	fmt.Println("delmit", string(b))
 | 
			
		||||
	return b == '(' || b == ')'
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (p ggcodeDelimiterParser) CanOpenCloser(opener, closer *parser.Delimiter) bool {
 | 
			
		||||
	fmt.Println("oopen")
 | 
			
		||||
	return opener.Char == '(' && closer.Char == ')'
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (p ggcodeDelimiterParser) OnMatch(consumes int) gast.Node {
 | 
			
		||||
	fmt.Println("out!")
 | 
			
		||||
	return p.Node
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// ----------------------
 | 
			
		||||
// AST node
 | 
			
		||||
// ----------------------
 | 
			
		||||
 | 
			
		||||
type ggcodeNode struct {
 | 
			
		||||
	gast.BaseBlock
 | 
			
		||||
	Name string
 | 
			
		||||
	Args map[string]string
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
var _ ast.Node = &ggcodeNode{}
 | 
			
		||||
 | 
			
		||||
func (n *ggcodeNode) Dump(source []byte, level int) {
 | 
			
		||||
	gast.DumpHelper(n, source, level, n.Args, nil)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
var kindGGCode = gast.NewNodeKind("ggcode")
 | 
			
		||||
 | 
			
		||||
func (n *ggcodeNode) Kind() gast.NodeKind {
 | 
			
		||||
	return kindGGCode
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// ----------------------
 | 
			
		||||
// Renderer
 | 
			
		||||
// ----------------------
 | 
			
		||||
 | 
			
		||||
type ggcodeHTMLRenderer struct {
 | 
			
		||||
	html.Config
 | 
			
		||||
	Opts MarkdownOptions
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func newGGCodeHTMLRenderer(markdownOpts MarkdownOptions, opts ...html.Option) renderer.NodeRenderer {
 | 
			
		||||
	r := &ggcodeHTMLRenderer{
 | 
			
		||||
		Opts:   markdownOpts,
 | 
			
		||||
		Config: html.NewConfig(),
 | 
			
		||||
	}
 | 
			
		||||
	for _, opt := range opts {
 | 
			
		||||
		opt.SetHTMLOption(&r.Config)
 | 
			
		||||
	}
 | 
			
		||||
	return r
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (r *ggcodeHTMLRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) {
 | 
			
		||||
	reg.Register(kindGGCode, r.render)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (r *ggcodeHTMLRenderer) render(w util.BufWriter, source []byte, n gast.Node, entering bool) (gast.WalkStatus, error) {
 | 
			
		||||
	node := n.(*ggcodeNode)
 | 
			
		||||
	var renderer ggcodeRenderer = defaultGGCodeRenderer
 | 
			
		||||
	if tag, ok := ggcodeTags[node.Name]; ok {
 | 
			
		||||
		if tag.Filter == nil || tag.Filter(r.Opts) {
 | 
			
		||||
			renderer = tag.Renderer
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
	err := renderer(ggcodeRendererContext{
 | 
			
		||||
		W:      w,
 | 
			
		||||
		Source: source,
 | 
			
		||||
		Opts:   r.Opts,
 | 
			
		||||
	}, node, entering)
 | 
			
		||||
	return gast.WalkContinue, err
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func defaultGGCodeRenderer(c ggcodeRendererContext, n *ggcodeNode, entering bool) error {
 | 
			
		||||
	if entering {
 | 
			
		||||
		c.W.WriteString("[unknown ggcode tag]")
 | 
			
		||||
	}
 | 
			
		||||
	return nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// ----------------------
 | 
			
		||||
// Extension
 | 
			
		||||
// ----------------------
 | 
			
		||||
 | 
			
		||||
type ggcodeExtension struct {
 | 
			
		||||
	Opts MarkdownOptions
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (e ggcodeExtension) Extend(m goldmark.Markdown) {
 | 
			
		||||
	m.Parser().AddOptions(parser.WithBlockParsers(
 | 
			
		||||
		util.Prioritized(ggcodeBlockParser{}, 500),
 | 
			
		||||
	))
 | 
			
		||||
	m.Parser().AddOptions(parser.WithInlineParsers(
 | 
			
		||||
		util.Prioritized(ggcodeInlineParser{}, 500),
 | 
			
		||||
	))
 | 
			
		||||
	m.Renderer().AddOptions(renderer.WithNodeRenderers(
 | 
			
		||||
		util.Prioritized(newGGCodeHTMLRenderer(e.Opts), 500),
 | 
			
		||||
	))
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -46,6 +46,24 @@ var DiscordMarkdown = makeGoldmark(
 | 
			
		|||
	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 {
 | 
			
		||||
	var buf bytes.Buffer
 | 
			
		||||
	if err := md.Convert([]byte(source), &buf); err != nil {
 | 
			
		||||
| 
						 | 
				
			
			@ -58,6 +76,7 @@ func ParseMarkdown(source string, md goldmark.Markdown) string {
 | 
			
		|||
type MarkdownOptions struct {
 | 
			
		||||
	Previews  bool
 | 
			
		||||
	Embeds    bool
 | 
			
		||||
	Education bool
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func makeGoldmark(opts ...goldmark.Option) goldmark.Markdown {
 | 
			
		||||
| 
						 | 
				
			
			@ -116,6 +135,9 @@ func makeGoldmarkExtensions(opts MarkdownOptions) []goldmark.Extender {
 | 
			
		|||
		BBCodeExtension{
 | 
			
		||||
			Preview: opts.Previews,
 | 
			
		||||
		},
 | 
			
		||||
		ggcodeExtension{
 | 
			
		||||
			Opts: opts,
 | 
			
		||||
		},
 | 
			
		||||
	)
 | 
			
		||||
 | 
			
		||||
	return extenders
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -0,0 +1,54 @@
 | 
			
		|||
package parsing
 | 
			
		||||
 | 
			
		||||
import "regexp"
 | 
			
		||||
 | 
			
		||||
func extract(re *regexp.Regexp, src []byte, subexpName string) []byte {
 | 
			
		||||
	m := re.FindSubmatch(src)
 | 
			
		||||
	if m == nil {
 | 
			
		||||
		return nil
 | 
			
		||||
	}
 | 
			
		||||
	return m[re.SubexpIndex(subexpName)]
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func extractMap(re *regexp.Regexp, src []byte) map[string][]byte {
 | 
			
		||||
	m := re.FindSubmatch(src)
 | 
			
		||||
	if m == nil {
 | 
			
		||||
		return nil
 | 
			
		||||
	}
 | 
			
		||||
	res := make(map[string][]byte)
 | 
			
		||||
	for _, name := range re.SubexpNames() {
 | 
			
		||||
		if name != "" {
 | 
			
		||||
			i := re.SubexpIndex(name)
 | 
			
		||||
			res[name] = m[i]
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
	res["all"] = m[0]
 | 
			
		||||
	return res
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func extractAll(re *regexp.Regexp, src []byte, subexpName string) [][]byte {
 | 
			
		||||
	m := re.FindAllSubmatch(src, -1)
 | 
			
		||||
	if m == nil {
 | 
			
		||||
		return nil
 | 
			
		||||
	}
 | 
			
		||||
	return m[re.SubexpIndex(subexpName)]
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func extractAllMap(re *regexp.Regexp, src []byte) map[string][][]byte {
 | 
			
		||||
	m := re.FindAllSubmatch(src, -1)
 | 
			
		||||
	if m == nil {
 | 
			
		||||
		return nil
 | 
			
		||||
	}
 | 
			
		||||
	res := make(map[string][][]byte)
 | 
			
		||||
	for i, name := range re.SubexpNames() {
 | 
			
		||||
		if name != "" {
 | 
			
		||||
			var vals [][]byte
 | 
			
		||||
			for _, specificMatch := range m {
 | 
			
		||||
				vals = append(vals, specificMatch[i])
 | 
			
		||||
			}
 | 
			
		||||
			res[name] = vals
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
	res["all"] = m[0]
 | 
			
		||||
	return res
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -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()
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -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
 | 
			
		||||
| 
						 | 
				
			
			@ -1,4 +1,4 @@
 | 
			
		|||
// +build js
 | 
			
		||||
//go:build js
 | 
			
		||||
 | 
			
		||||
package main
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -12,6 +12,9 @@ func main() {
 | 
			
		|||
	js.Global().Set("parseMarkdown", js.FuncOf(func(this js.Value, args []js.Value) interface{} {
 | 
			
		||||
		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
 | 
			
		||||
	<-done // block forever
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -197,6 +197,14 @@ article code {
 | 
			
		|||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.c--inherit {
 | 
			
		||||
  color: inherit;
 | 
			
		||||
 | 
			
		||||
  &:hover, &:active {
 | 
			
		||||
    color: inherit;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.b--theme {
 | 
			
		||||
  @include usevar(border-color, theme-color);
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -0,0 +1,9 @@
 | 
			
		|||
.edu-article {
 | 
			
		||||
    .edu-resource {
 | 
			
		||||
        @extend .pa3, .bg--dim, .br3;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    .note {
 | 
			
		||||
        color: red;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -12,6 +12,7 @@
 | 
			
		|||
@import 'content';
 | 
			
		||||
@import 'editor';
 | 
			
		||||
@import 'episodes';
 | 
			
		||||
@import 'education';
 | 
			
		||||
@import 'forms';
 | 
			
		||||
@import 'forum';
 | 
			
		||||
@import 'header';
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -211,9 +211,11 @@ func UserToTemplate(u *models.User, currentTheme string) User {
 | 
			
		|||
		DarkTheme: u.DarkTheme,
 | 
			
		||||
		Timezone:  u.Timezone,
 | 
			
		||||
 | 
			
		||||
		CanEditLibrary:                      u.CanEditLibrary,
 | 
			
		||||
		DiscordSaveShowcase:                 u.DiscordSaveShowcase,
 | 
			
		||||
		DiscordDeleteSnippetOnMessageDelete: u.DiscordDeleteSnippetOnMessageDelete,
 | 
			
		||||
 | 
			
		||||
		IsEduTester: u.CanSeeUnpublishedEducationContent(),
 | 
			
		||||
		IsEduAuthor: u.CanAuthorEducation(),
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -493,6 +495,33 @@ func TagToTemplate(t *models.Tag) Tag {
 | 
			
		|||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func EducationArticleToTemplate(a *models.EduArticle) EduArticle {
 | 
			
		||||
	res := EduArticle{
 | 
			
		||||
		Title:       a.Title,
 | 
			
		||||
		Slug:        a.Slug,
 | 
			
		||||
		Description: a.Description,
 | 
			
		||||
		Published:   a.Published,
 | 
			
		||||
 | 
			
		||||
		Url:       hmnurl.BuildEducationArticle(a.Slug),
 | 
			
		||||
		EditUrl:   hmnurl.BuildEducationArticleEdit(a.Slug),
 | 
			
		||||
		DeleteUrl: hmnurl.BuildEducationArticleDelete(a.Slug),
 | 
			
		||||
 | 
			
		||||
		Content: "NO CONTENT HERE FOLKS YOU DID A BUG",
 | 
			
		||||
	}
 | 
			
		||||
	switch a.Type {
 | 
			
		||||
	case models.EduArticleTypeArticle:
 | 
			
		||||
		res.Type = "article"
 | 
			
		||||
	case models.EduArticleTypeGlossary:
 | 
			
		||||
		res.Type = "glossary"
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if a.CurrentVersion != nil {
 | 
			
		||||
		res.Content = template.HTML(a.CurrentVersion.ContentHTML)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return res
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func maybeString(s *string) string {
 | 
			
		||||
	if s == nil {
 | 
			
		||||
		return ""
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -19,15 +19,15 @@
 | 
			
		|||
 | 
			
		||||
{{ define "content" }}
 | 
			
		||||
<div class="content-block ph3 ph0-ns">
 | 
			
		||||
    {{ if not .CanEditTitle }}
 | 
			
		||||
        <h2>{{ .Title }}</h2>
 | 
			
		||||
    {{ if not .CanEditPostTitle }}
 | 
			
		||||
        <h2>{{ .PostTitle }}</h2>
 | 
			
		||||
    {{ end }}
 | 
			
		||||
    <div class="flex flex-column flex-row-ns">
 | 
			
		||||
        <form id="form" action="{{ .SubmitUrl }}" method="post" class="flex-fair-ns overflow-hidden">
 | 
			
		||||
            {{ csrftoken .Session }}
 | 
			
		||||
 | 
			
		||||
            {{ if .CanEditTitle }}
 | 
			
		||||
                <input id="title" class="b w-100 mb1" name="title" type="text" placeholder="Post title..." value="{{ .Title }}" />
 | 
			
		||||
            {{ if .CanEditPostTitle }}
 | 
			
		||||
                <input id="title" class="b w-100 mb1" name="title" type="text" placeholder="Post title..." value="{{ .PostTitle }}" />
 | 
			
		||||
            {{ end }}
 | 
			
		||||
            {{/* TODO: Reintroduce the toolbar in a way that makes sense for Markdown */}}
 | 
			
		||||
            {{/*
 | 
			
		||||
| 
						 | 
				
			
			@ -79,8 +79,36 @@
 | 
			
		|||
                    {{ template "forum_post_standalone.html" . }}
 | 
			
		||||
                </div>
 | 
			
		||||
            {{ end }}
 | 
			
		||||
 | 
			
		||||
            {{ if .ShowEduOptions }}
 | 
			
		||||
                {{/* Hope you have a .Article field! */}}
 | 
			
		||||
                <div class="bg--dim br3 pa3 mt3">
 | 
			
		||||
                    <h4>Education Options</h4>
 | 
			
		||||
                    <div class="mb2">
 | 
			
		||||
                        <label for="slug">Slug:</label>
 | 
			
		||||
                        <input name="slug" maxlength="255" type="text" id="slug" required value="{{ .Article.Slug }}" />
 | 
			
		||||
                    </div>
 | 
			
		||||
                    <div class="mb2">
 | 
			
		||||
                        <label for="type">Type:</label>
 | 
			
		||||
                        <select name="type" id="type">
 | 
			
		||||
                            <option value="article"  {{ if eq .Article.Type "article"  }}selected{{ end }}>Article</option>
 | 
			
		||||
                            <option value="glossary" {{ if eq .Article.Type "glossary" }}selected{{ end }}>Glossary Term</option>
 | 
			
		||||
                        </select>
 | 
			
		||||
                    </div>
 | 
			
		||||
                    <div class="mb2">
 | 
			
		||||
                        <label for="description">Description:</label>
 | 
			
		||||
                        <div>
 | 
			
		||||
                            <textarea name="description" id="slug" required>{{ .Article.Description }}</textarea>
 | 
			
		||||
                        </div>
 | 
			
		||||
                    </div>
 | 
			
		||||
                    <div class="mb2">
 | 
			
		||||
                        <label for="published">Published:</label>
 | 
			
		||||
                        <input name="published" id="published" type="checkbox" {{ if .Article.Published }}checked{{ end }}>
 | 
			
		||||
                    </div>
 | 
			
		||||
                </div>
 | 
			
		||||
            {{ end }}
 | 
			
		||||
        </form>
 | 
			
		||||
        <div id="preview-container" class="post post-preview mathjax flex-fair-ns overflow-auto mv3 mv0-ns ml3-ns pa3 br3 bg--dim">
 | 
			
		||||
        <div id="preview-container" class="post post-preview mathjax flex-fair-ns overflow-auto mv3 mv0-ns ml3-ns pa3 br3 bg--dim {{ .PreviewClass }}">
 | 
			
		||||
            <div id="preview" class="post-content"></div>
 | 
			
		||||
        </div>
 | 
			
		||||
        <input type="file" multiple name="file_input" id="file_input" class="dn" />{{/* NOTE(asaf): Placing this outside the form to avoid submitting it to the server by accident */}}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -0,0 +1,19 @@
 | 
			
		|||
{{ template "base.html" . }}
 | 
			
		||||
 | 
			
		||||
{{ define "content" }}
 | 
			
		||||
    <h1>{{ .Title }}</h1>
 | 
			
		||||
    {{ if and .User .User.IsEduAuthor }}
 | 
			
		||||
        <div class="mb3">
 | 
			
		||||
            <a href="{{ .EditUrl }}" title="Edit">✎ Edit</a>
 | 
			
		||||
            <a href="{{ .DeleteUrl }}" title="Delete">✖ Delete</a>
 | 
			
		||||
        </div>
 | 
			
		||||
    {{ end }}
 | 
			
		||||
    <div class="flex">
 | 
			
		||||
        <div class="edu-article flex-grow-1 post-content">
 | 
			
		||||
            {{ .Article.Content }}
 | 
			
		||||
        </div>
 | 
			
		||||
        <div class="ml3 flex-shrink-0 w5">
 | 
			
		||||
            I'm a sidebar!
 | 
			
		||||
        </div>
 | 
			
		||||
    </div>
 | 
			
		||||
{{ end }}
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,15 @@
 | 
			
		|||
{{ template "base.html" . }}
 | 
			
		||||
 | 
			
		||||
{{ define "content" }}
 | 
			
		||||
<div class="mw7 margin-center">
 | 
			
		||||
    <h3 class="mb3">Are you sure you want to delete this article?</h3>
 | 
			
		||||
    <div class="bg--dim pa3 br3 tl post-content">
 | 
			
		||||
        <h1>{{ .Article.Title }}</h1>
 | 
			
		||||
        {{ .Article.Content }}
 | 
			
		||||
    </div>
 | 
			
		||||
    <form action="{{ .SubmitUrl }}" method="POST" class="pv3 flex justify-end">
 | 
			
		||||
        {{ csrftoken .Session }}
 | 
			
		||||
        <input type="submit" value="Delete Article">
 | 
			
		||||
    </form>
 | 
			
		||||
</div>
 | 
			
		||||
{{ end }}
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,5 @@
 | 
			
		|||
{{ template "base.html" . }}
 | 
			
		||||
 | 
			
		||||
{{ define "content" }}
 | 
			
		||||
    O YES
 | 
			
		||||
{{ end }}
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,43 @@
 | 
			
		|||
{{ template "base.html" . }}
 | 
			
		||||
 | 
			
		||||
{{ define "content" }}
 | 
			
		||||
    <h1>Learn the Handmade way.</h1>
 | 
			
		||||
 | 
			
		||||
    <h2>Guides</h2>
 | 
			
		||||
 | 
			
		||||
    {{ if .User.IsEduAuthor }}
 | 
			
		||||
        <div class="mb2">
 | 
			
		||||
            <a href="{{ .NewArticleUrl }}"><span class="big pr1">+</span> New Article</a>
 | 
			
		||||
        </div>
 | 
			
		||||
    {{ end }}
 | 
			
		||||
 | 
			
		||||
    <div class="flex flex-column g3 mb3">
 | 
			
		||||
        {{ range .Articles }}
 | 
			
		||||
            <a class="c--inherit flex flex-column pa3 bg--dim br2" href="{{ .Url }}" >
 | 
			
		||||
                <h3 class="mb1 link">{{ .Title }}</h3>
 | 
			
		||||
                <div>{{ .Description }}</div>
 | 
			
		||||
            </a>
 | 
			
		||||
        {{ end }}
 | 
			
		||||
    </div>
 | 
			
		||||
 | 
			
		||||
    <h2>What makes us different?</h2>
 | 
			
		||||
 | 
			
		||||
    <div class="flex flex-column flex-row-ns g3">
 | 
			
		||||
        <div class="flex-fair bg--dim pa3 br2">
 | 
			
		||||
            <h3>Real material.</h3>
 | 
			
		||||
            
 | 
			
		||||
            We equip you to go straight to the source. Our guides are structured around books and articles written by experts. We give you high-quality material to read, and the context to understand it. You do the rest.
 | 
			
		||||
        </div>
 | 
			
		||||
        <div class="flex-fair bg--dim pa3 br3">
 | 
			
		||||
            <h3>For any skill level.</h3>
 | 
			
		||||
            
 | 
			
		||||
            Each guide runs the gamut from beginner to advanced. Whether you're new to a topic or have been practicing it for years, read through our guides and you'll find something new.
 | 
			
		||||
        </div>
 | 
			
		||||
        <div class="flex-fair bg--dim pa3 br3">
 | 
			
		||||
            <h3>Designed for programmers.</h3>
 | 
			
		||||
            
 | 
			
		||||
            We're not here to teach you how to program. We're here to teach you a specific topic.
 | 
			
		||||
        </div>
 | 
			
		||||
    </div>
 | 
			
		||||
 | 
			
		||||
{{ end }}
 | 
			
		||||
| 
						 | 
				
			
			@ -84,7 +84,7 @@
 | 
			
		|||
                    <div class="root-item">
 | 
			
		||||
                        <a>Resources <div class="dib svgicon ml1">{{ svg "chevron-down-thick" }}</div></a>
 | 
			
		||||
                        <div class="submenu b--theme-dark">
 | 
			
		||||
                            <a href="{{ .Header.LibraryUrl }}">Library</a>
 | 
			
		||||
                            <a href="{{ .Header.EducationUrl }}">Education</a>
 | 
			
		||||
                        </div>
 | 
			
		||||
                    </div>
 | 
			
		||||
                </div>
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -97,6 +97,7 @@
 | 
			
		|||
            previewWorker.postMessage({
 | 
			
		||||
                elementID: inputEl.id,
 | 
			
		||||
                markdown: inputEl.value,
 | 
			
		||||
                parserName: '{{ or .ParserName "parseMarkdown" }}',
 | 
			
		||||
            });
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,5 +1,5 @@
 | 
			
		|||
<!DOCTYPE html{{ if .OpenGraphItems }} prefix="og: http://ogp.me/ns#"{{ end }}>
 | 
			
		||||
<html lang="en-US">
 | 
			
		||||
<!DOCTYPE html>
 | 
			
		||||
<html lang="en-US" {{ if .OpenGraphItems }} prefix="og: http://ogp.me/ns#"{{ end }}>
 | 
			
		||||
<head>
 | 
			
		||||
    <meta charset="utf-8">
 | 
			
		||||
    <meta name="viewport" content="width=device-width, initial-scale=1">
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,4 +1,7 @@
 | 
			
		|||
importScripts('/public/go_wasm_exec.js');
 | 
			
		||||
importScripts('{{ static "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
 | 
			
		||||
| 
						 | 
				
			
			@ -9,13 +12,13 @@ let wasmLoaded = false;
 | 
			
		|||
let jobs = {};
 | 
			
		||||
 | 
			
		||||
onmessage = ({ data }) => {
 | 
			
		||||
    const { elementID, markdown } = data;
 | 
			
		||||
    jobs[elementID] = markdown;
 | 
			
		||||
    const { elementID, markdown, parserName } = data;
 | 
			
		||||
    jobs[elementID] = { markdown, parserName };
 | 
			
		||||
    setTimeout(doPreview, 0);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const go = new Go();
 | 
			
		||||
WebAssembly.instantiateStreaming(fetch('/public/parsing.wasm'), go.importObject)
 | 
			
		||||
WebAssembly.instantiateStreaming(fetch('{{ static "parsing.wasm" }}'), go.importObject)
 | 
			
		||||
    .then(result => {
 | 
			
		||||
        go.run(result.instance); // don't await this; we want it to be continuously running
 | 
			
		||||
        wasmLoaded = true;
 | 
			
		||||
| 
						 | 
				
			
			@ -27,8 +30,8 @@ const doPreview = () => {
 | 
			
		|||
        return;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    for (const [elementID, markdown] of Object.entries(jobs)) {
 | 
			
		||||
        const html = parseMarkdown(markdown);
 | 
			
		||||
    for (const [elementID, { markdown, parserName }] of Object.entries(jobs)) {
 | 
			
		||||
        const html = global[parserName](markdown);
 | 
			
		||||
        postMessage({
 | 
			
		||||
            elementID: elementID,
 | 
			
		||||
            html: html,
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -51,8 +51,8 @@ type Header struct {
 | 
			
		|||
	PodcastUrl      string
 | 
			
		||||
	FishbowlUrl     string
 | 
			
		||||
	ForumsUrl       string
 | 
			
		||||
	LibraryUrl      string
 | 
			
		||||
	ConferencesUrl  string
 | 
			
		||||
	EducationUrl    string
 | 
			
		||||
 | 
			
		||||
	Project *ProjectHeader
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -189,9 +189,11 @@ type User struct {
 | 
			
		|||
	ShowEmail bool
 | 
			
		||||
	Timezone  string
 | 
			
		||||
 | 
			
		||||
	CanEditLibrary                      bool
 | 
			
		||||
	DiscordSaveShowcase                 bool
 | 
			
		||||
	DiscordDeleteSnippetOnMessageDelete bool
 | 
			
		||||
 | 
			
		||||
	IsEduTester bool
 | 
			
		||||
	IsEduAuthor bool
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type Link struct {
 | 
			
		||||
| 
						 | 
				
			
			@ -385,3 +387,17 @@ type Tag struct {
 | 
			
		|||
	Text string
 | 
			
		||||
	Url  string
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type EduArticle struct {
 | 
			
		||||
	Title       string
 | 
			
		||||
	Slug        string
 | 
			
		||||
	Description string
 | 
			
		||||
	Published   bool
 | 
			
		||||
	Type        string
 | 
			
		||||
 | 
			
		||||
	Url       string
 | 
			
		||||
	EditUrl   string
 | 
			
		||||
	DeleteUrl string
 | 
			
		||||
 | 
			
		||||
	Content template.HTML
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -22,7 +22,7 @@ func OrDefault[T comparable](v T, def T) T {
 | 
			
		|||
 | 
			
		||||
// Takes an (error) return and panics if there is an error.
 | 
			
		||||
// Helps avoid `if err != nil` in scripts. Use sparingly in real code.
 | 
			
		||||
func Must0(err error) {
 | 
			
		||||
func Must(err error) {
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		panic(err)
 | 
			
		||||
	}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -74,8 +74,8 @@ func getBaseData(c *RequestContext, title string, breadcrumbs []templates.Breadc
 | 
			
		|||
			PodcastUrl:      hmnurl.BuildPodcast(),
 | 
			
		||||
			FishbowlUrl:     hmnurl.BuildFishbowlIndex(),
 | 
			
		||||
			ForumsUrl:       hmnurl.HMNProjectContext.BuildForum(nil, 1),
 | 
			
		||||
			LibraryUrl:      hmnurl.BuildLibrary(),
 | 
			
		||||
			ConferencesUrl:  hmnurl.BuildConferences(),
 | 
			
		||||
			EducationUrl:    hmnurl.BuildEducationIndex(),
 | 
			
		||||
		},
 | 
			
		||||
		Footer: templates.Footer{
 | 
			
		||||
			HomepageUrl:                hmnurl.BuildHomepage(),
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -0,0 +1,426 @@
 | 
			
		|||
package website
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"context"
 | 
			
		||||
	"errors"
 | 
			
		||||
	"fmt"
 | 
			
		||||
	"html/template"
 | 
			
		||||
	"net/http"
 | 
			
		||||
	"net/url"
 | 
			
		||||
	"regexp"
 | 
			
		||||
	"time"
 | 
			
		||||
 | 
			
		||||
	"git.handmade.network/hmn/hmn/src/db"
 | 
			
		||||
	"git.handmade.network/hmn/hmn/src/hmnurl"
 | 
			
		||||
	"git.handmade.network/hmn/hmn/src/models"
 | 
			
		||||
	"git.handmade.network/hmn/hmn/src/parsing"
 | 
			
		||||
	"git.handmade.network/hmn/hmn/src/templates"
 | 
			
		||||
	"git.handmade.network/hmn/hmn/src/utils"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
func EducationIndex(c *RequestContext) ResponseData {
 | 
			
		||||
	type indexData struct {
 | 
			
		||||
		templates.BaseData
 | 
			
		||||
		Articles      []templates.EduArticle
 | 
			
		||||
		NewArticleUrl string
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	articles, err := fetchEduArticles(c, c.Conn, models.EduArticleTypeArticle, c.CurrentUser)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		panic(err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	var tmplArticles []templates.EduArticle
 | 
			
		||||
	for _, article := range articles {
 | 
			
		||||
		tmplArticles = append(tmplArticles, templates.EducationArticleToTemplate(&article))
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	tmpl := indexData{
 | 
			
		||||
		BaseData:      getBaseData(c, "Handmade Education", nil),
 | 
			
		||||
		Articles:      tmplArticles,
 | 
			
		||||
		NewArticleUrl: hmnurl.BuildEducationArticleNew(),
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	var res ResponseData
 | 
			
		||||
	res.MustWriteTemplate("education_index.html", tmpl, c.Perf)
 | 
			
		||||
	return res
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func EducationGlossary(c *RequestContext) ResponseData {
 | 
			
		||||
	type glossaryData struct {
 | 
			
		||||
		templates.BaseData
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	tmpl := glossaryData{
 | 
			
		||||
		BaseData: getBaseData(c, "Handmade Education", nil),
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	var res ResponseData
 | 
			
		||||
	res.MustWriteTemplate("education_glossary.html", tmpl, c.Perf)
 | 
			
		||||
	return res
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
var reEduEditorsNote = regexp.MustCompile(`<span\s*class="note".*?>.*?</span>`)
 | 
			
		||||
 | 
			
		||||
func EducationArticle(c *RequestContext) ResponseData {
 | 
			
		||||
	type articleData struct {
 | 
			
		||||
		templates.BaseData
 | 
			
		||||
		Article   templates.EduArticle
 | 
			
		||||
		EditUrl   string
 | 
			
		||||
		DeleteUrl string
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	article, err := fetchEduArticle(c, c.Conn, c.PathParams["slug"], models.EduArticleTypeArticle, c.CurrentUser)
 | 
			
		||||
	if errors.Is(err, db.NotFound) {
 | 
			
		||||
		return FourOhFour(c)
 | 
			
		||||
	} else if err != nil {
 | 
			
		||||
		return c.ErrorResponse(http.StatusInternalServerError, err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	tmpl := articleData{
 | 
			
		||||
		BaseData:  getBaseData(c, article.Title, nil),
 | 
			
		||||
		Article:   templates.EducationArticleToTemplate(article),
 | 
			
		||||
		EditUrl:   hmnurl.BuildEducationArticleEdit(article.Slug),
 | 
			
		||||
		DeleteUrl: hmnurl.BuildEducationArticleDelete(article.Slug),
 | 
			
		||||
	}
 | 
			
		||||
	tmpl.OpenGraphItems = append(tmpl.OpenGraphItems,
 | 
			
		||||
		templates.OpenGraphItem{Property: "og:description", Value: string(article.Description)},
 | 
			
		||||
	)
 | 
			
		||||
	tmpl.Breadcrumbs = []templates.Breadcrumb{
 | 
			
		||||
		{Name: "Education", Url: hmnurl.BuildEducationIndex()},
 | 
			
		||||
		{Name: article.Title, Url: hmnurl.BuildEducationArticle(article.Slug)},
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Remove editor's notes
 | 
			
		||||
	if c.CurrentUser == nil || !c.CurrentUser.CanAuthorEducation() {
 | 
			
		||||
		tmpl.Article.Content = template.HTML(reEduEditorsNote.ReplaceAllLiteralString(string(tmpl.Article.Content), ""))
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	var res ResponseData
 | 
			
		||||
	res.MustWriteTemplate("education_article.html", tmpl, c.Perf)
 | 
			
		||||
	return res
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func EducationArticleNew(c *RequestContext) ResponseData {
 | 
			
		||||
	type adminData struct {
 | 
			
		||||
		editorData
 | 
			
		||||
		Article map[string]interface{}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	tmpl := adminData{
 | 
			
		||||
		editorData: getEditorDataForEduArticle(c.UrlContext, c.CurrentUser, getBaseData(c, "New Education Article", nil), nil),
 | 
			
		||||
	}
 | 
			
		||||
	tmpl.editorData.SubmitUrl = hmnurl.BuildEducationArticleNew()
 | 
			
		||||
 | 
			
		||||
	var res ResponseData
 | 
			
		||||
	res.MustWriteTemplate("editor.html", tmpl, c.Perf)
 | 
			
		||||
	return res
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func EducationArticleNewSubmit(c *RequestContext) ResponseData {
 | 
			
		||||
	form, err := c.GetFormValues()
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return c.ErrorResponse(http.StatusBadRequest, err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	art, ver := getEduArticleFromForm(form)
 | 
			
		||||
 | 
			
		||||
	dupe := 0 < db.MustQueryOneScalar[int](c, c.Conn,
 | 
			
		||||
		`
 | 
			
		||||
		SELECT COUNT(*) FROM education_article
 | 
			
		||||
		WHERE slug = $1
 | 
			
		||||
		`,
 | 
			
		||||
		art.Slug,
 | 
			
		||||
	)
 | 
			
		||||
	if dupe {
 | 
			
		||||
		return c.RejectRequest("A resource already exists with that slug.")
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	createEduArticle(c, art, ver)
 | 
			
		||||
 | 
			
		||||
	res := c.Redirect(eduArticleURL(&art), http.StatusSeeOther)
 | 
			
		||||
	res.AddFutureNotice("success", "Created new education article.")
 | 
			
		||||
	return res
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func EducationArticleEdit(c *RequestContext) ResponseData {
 | 
			
		||||
	type adminData struct {
 | 
			
		||||
		editorData
 | 
			
		||||
		Article templates.EduArticle
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	article, err := fetchEduArticle(c, c.Conn, c.PathParams["slug"], 0, c.CurrentUser)
 | 
			
		||||
	if errors.Is(err, db.NotFound) {
 | 
			
		||||
		return FourOhFour(c)
 | 
			
		||||
	} else if err != nil {
 | 
			
		||||
		panic(err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	tmpl := adminData{
 | 
			
		||||
		editorData: getEditorDataForEduArticle(c.UrlContext, c.CurrentUser, getBaseData(c, "Edit Education Article", nil), article),
 | 
			
		||||
		Article:    templates.EducationArticleToTemplate(article),
 | 
			
		||||
	}
 | 
			
		||||
	tmpl.editorData.SubmitUrl = hmnurl.BuildEducationArticleEdit(c.PathParams["slug"])
 | 
			
		||||
 | 
			
		||||
	var res ResponseData
 | 
			
		||||
	res.MustWriteTemplate("editor.html", tmpl, c.Perf)
 | 
			
		||||
	return res
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func EducationArticleEditSubmit(c *RequestContext) ResponseData {
 | 
			
		||||
	form, err := c.GetFormValues()
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return c.ErrorResponse(http.StatusBadRequest, err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	_, err = fetchEduArticle(c, c.Conn, c.PathParams["slug"], 0, c.CurrentUser)
 | 
			
		||||
	if errors.Is(err, db.NotFound) {
 | 
			
		||||
		return FourOhFour(c)
 | 
			
		||||
	} else if err != nil {
 | 
			
		||||
		panic(err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	art, ver := getEduArticleFromForm(form)
 | 
			
		||||
	updateEduArticle(c, c.PathParams["slug"], art, ver)
 | 
			
		||||
 | 
			
		||||
	res := c.Redirect(eduArticleURL(&art), http.StatusSeeOther)
 | 
			
		||||
	res.AddFutureNotice("success", "Edited education article.")
 | 
			
		||||
	return res
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func EducationArticleDelete(c *RequestContext) ResponseData {
 | 
			
		||||
	article, err := fetchEduArticle(c, c.Conn, c.PathParams["slug"], 0, c.CurrentUser)
 | 
			
		||||
	if errors.Is(err, db.NotFound) {
 | 
			
		||||
		return FourOhFour(c)
 | 
			
		||||
	} else if err != nil {
 | 
			
		||||
		panic(err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	type deleteData struct {
 | 
			
		||||
		templates.BaseData
 | 
			
		||||
		Article   templates.EduArticle
 | 
			
		||||
		SubmitUrl string
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	baseData := getBaseData(c, fmt.Sprintf("Deleting \"%s\"", article.Title), nil)
 | 
			
		||||
 | 
			
		||||
	var res ResponseData
 | 
			
		||||
	res.MustWriteTemplate("education_article_delete.html", deleteData{
 | 
			
		||||
		BaseData:  baseData,
 | 
			
		||||
		Article:   templates.EducationArticleToTemplate(article),
 | 
			
		||||
		SubmitUrl: hmnurl.BuildEducationArticleDelete(article.Slug),
 | 
			
		||||
	}, c.Perf)
 | 
			
		||||
	return res
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func EducationArticleDeleteSubmit(c *RequestContext) ResponseData {
 | 
			
		||||
	_, err := c.Conn.Exec(c, `DELETE FROM education_article WHERE slug = $1`, c.PathParams["slug"])
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		panic(err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	res := c.Redirect(hmnurl.BuildEducationIndex(), http.StatusSeeOther)
 | 
			
		||||
	res.AddFutureNotice("success", "Article deleted.")
 | 
			
		||||
	return res
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func fetchEduArticles(
 | 
			
		||||
	ctx context.Context,
 | 
			
		||||
	dbConn db.ConnOrTx,
 | 
			
		||||
	t models.EduArticleType,
 | 
			
		||||
	currentUser *models.User,
 | 
			
		||||
) ([]models.EduArticle, error) {
 | 
			
		||||
	type eduArticleResult struct {
 | 
			
		||||
		Article        models.EduArticle        `db:"a"`
 | 
			
		||||
		CurrentVersion models.EduArticleVersion `db:"v"`
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	var qb db.QueryBuilder
 | 
			
		||||
	qb.Add(`
 | 
			
		||||
		SELECT $columns
 | 
			
		||||
		FROM
 | 
			
		||||
			education_article AS a
 | 
			
		||||
			JOIN education_article_version AS v ON a.current_version = v.id
 | 
			
		||||
		WHERE
 | 
			
		||||
			TRUE
 | 
			
		||||
	`)
 | 
			
		||||
	if t != 0 {
 | 
			
		||||
		qb.Add(`AND a.type = $?`, t)
 | 
			
		||||
	}
 | 
			
		||||
	if currentUser == nil || !currentUser.CanSeeUnpublishedEducationContent() {
 | 
			
		||||
		qb.Add(`AND NOT a.published`)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	articles, err := db.Query[eduArticleResult](ctx, dbConn, qb.String(), qb.Args()...)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return nil, err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	var res []models.EduArticle
 | 
			
		||||
	for _, article := range articles {
 | 
			
		||||
		ver := article.CurrentVersion
 | 
			
		||||
		article.Article.CurrentVersion = &ver
 | 
			
		||||
		res = append(res, article.Article)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return res, nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func fetchEduArticle(
 | 
			
		||||
	ctx context.Context,
 | 
			
		||||
	dbConn db.ConnOrTx,
 | 
			
		||||
	slug string,
 | 
			
		||||
	t models.EduArticleType,
 | 
			
		||||
	currentUser *models.User,
 | 
			
		||||
) (*models.EduArticle, error) {
 | 
			
		||||
	type eduArticleResult struct {
 | 
			
		||||
		Article        models.EduArticle        `db:"a"`
 | 
			
		||||
		CurrentVersion models.EduArticleVersion `db:"v"`
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	var qb db.QueryBuilder
 | 
			
		||||
	qb.Add(
 | 
			
		||||
		`
 | 
			
		||||
		SELECT $columns
 | 
			
		||||
		FROM
 | 
			
		||||
			education_article AS a
 | 
			
		||||
			JOIN education_article_version AS v ON a.current_version = v.id
 | 
			
		||||
		WHERE
 | 
			
		||||
			a.slug = $?
 | 
			
		||||
		`,
 | 
			
		||||
		slug,
 | 
			
		||||
	)
 | 
			
		||||
	if t != 0 {
 | 
			
		||||
		qb.Add(`AND a.type = $?`, t)
 | 
			
		||||
	}
 | 
			
		||||
	if currentUser == nil || !currentUser.CanSeeUnpublishedEducationContent() {
 | 
			
		||||
		qb.Add(`AND NOT a.published`)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	res, err := db.QueryOne[eduArticleResult](ctx, dbConn, qb.String(), qb.Args()...)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return nil, err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	res.Article.CurrentVersion = &res.CurrentVersion
 | 
			
		||||
	return &res.Article, nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func getEditorDataForEduArticle(
 | 
			
		||||
	urlContext *hmnurl.UrlContext,
 | 
			
		||||
	currentUser *models.User,
 | 
			
		||||
	baseData templates.BaseData,
 | 
			
		||||
	article *models.EduArticle,
 | 
			
		||||
) editorData {
 | 
			
		||||
	result := editorData{
 | 
			
		||||
		BaseData:    baseData,
 | 
			
		||||
		SubmitLabel: "Submit",
 | 
			
		||||
 | 
			
		||||
		CanEditPostTitle: true,
 | 
			
		||||
		MaxFileSize:      AssetMaxSize(currentUser),
 | 
			
		||||
		UploadUrl:        urlContext.BuildAssetUpload(),
 | 
			
		||||
		ShowEduOptions:   true,
 | 
			
		||||
		PreviewClass:     "edu-article",
 | 
			
		||||
 | 
			
		||||
		ParserName: "parseMarkdownEdu",
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if article != nil {
 | 
			
		||||
		result.PostTitle = article.Title
 | 
			
		||||
		result.EditInitialContents = article.CurrentVersion.ContentRaw
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return result
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func getEduArticleFromForm(form url.Values) (art models.EduArticle, ver models.EduArticleVersion) {
 | 
			
		||||
	art.Title = form.Get("title")
 | 
			
		||||
	art.Slug = form.Get("slug")
 | 
			
		||||
	art.Description = form.Get("description")
 | 
			
		||||
	switch form.Get("type") {
 | 
			
		||||
	case "article":
 | 
			
		||||
		art.Type = models.EduArticleTypeArticle
 | 
			
		||||
	case "glossary":
 | 
			
		||||
		art.Type = models.EduArticleTypeGlossary
 | 
			
		||||
	default:
 | 
			
		||||
		panic(fmt.Errorf("unknown education article type: %s", form.Get("type")))
 | 
			
		||||
	}
 | 
			
		||||
	art.Published = form.Get("published") != ""
 | 
			
		||||
 | 
			
		||||
	ver.ContentRaw = form.Get("body")
 | 
			
		||||
	ver.ContentHTML = parsing.ParseMarkdown(ver.ContentRaw, parsing.EducationRealMarkdown)
 | 
			
		||||
 | 
			
		||||
	return
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func createEduArticle(c *RequestContext, art models.EduArticle, ver models.EduArticleVersion) {
 | 
			
		||||
	tx := utils.Must1(c.Conn.Begin(c))
 | 
			
		||||
	defer tx.Rollback(c)
 | 
			
		||||
	{
 | 
			
		||||
		articleID := db.MustQueryOneScalar[int](c, tx,
 | 
			
		||||
			`
 | 
			
		||||
			INSERT INTO education_article (title, slug, description, published, type, current_version)
 | 
			
		||||
			VALUES                        ($1,    $2,   $3,          $4,        $5,   -1)
 | 
			
		||||
			RETURNING id
 | 
			
		||||
			`,
 | 
			
		||||
			art.Title, art.Slug, art.Description, art.Published, art.Type,
 | 
			
		||||
		)
 | 
			
		||||
		versionID := db.MustQueryOneScalar[int](c, tx,
 | 
			
		||||
			`
 | 
			
		||||
			INSERT INTO education_article_version (article_id, date, editor_id, content_raw, content_html)
 | 
			
		||||
			VALUES                                ($1,         $2,   $3,        $4,          $5          )
 | 
			
		||||
			RETURNING id
 | 
			
		||||
			`,
 | 
			
		||||
			articleID, time.Now(), c.CurrentUser.ID, ver.ContentRaw, ver.ContentHTML,
 | 
			
		||||
		)
 | 
			
		||||
		tx.Exec(c,
 | 
			
		||||
			`UPDATE education_article SET current_version = $1 WHERE id = $2`,
 | 
			
		||||
			versionID, articleID,
 | 
			
		||||
		)
 | 
			
		||||
	}
 | 
			
		||||
	utils.Must(tx.Commit(c))
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func updateEduArticle(c *RequestContext, slug string, art models.EduArticle, ver models.EduArticleVersion) {
 | 
			
		||||
	tx := utils.Must1(c.Conn.Begin(c))
 | 
			
		||||
	defer tx.Rollback(c)
 | 
			
		||||
	{
 | 
			
		||||
		articleID := db.MustQueryOneScalar[int](c, tx,
 | 
			
		||||
			`SELECT id FROM education_article WHERE slug = $1`,
 | 
			
		||||
			slug,
 | 
			
		||||
		)
 | 
			
		||||
		versionID := db.MustQueryOneScalar[int](c, tx,
 | 
			
		||||
			`
 | 
			
		||||
			INSERT INTO education_article_version (article_id, date, editor_id, content_raw, content_html)
 | 
			
		||||
			VALUES                                ($1,         $2,   $3,        $4,          $5          )
 | 
			
		||||
			RETURNING id
 | 
			
		||||
			`,
 | 
			
		||||
			articleID, time.Now(), c.CurrentUser.ID, ver.ContentRaw, ver.ContentHTML,
 | 
			
		||||
		)
 | 
			
		||||
		tx.Exec(c,
 | 
			
		||||
			`
 | 
			
		||||
			UPDATE education_article
 | 
			
		||||
			SET
 | 
			
		||||
				title = $1, slug = $2, description = $3, published = $4, type = $5,
 | 
			
		||||
				current_version = $6
 | 
			
		||||
			WHERE
 | 
			
		||||
				id = $7
 | 
			
		||||
			`,
 | 
			
		||||
			art.Title, art.Slug, art.Description, art.Published, art.Type,
 | 
			
		||||
			versionID,
 | 
			
		||||
			articleID,
 | 
			
		||||
		)
 | 
			
		||||
	}
 | 
			
		||||
	utils.Must(tx.Commit(c))
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func eduArticleURL(a *models.EduArticle) string {
 | 
			
		||||
	switch a.Type {
 | 
			
		||||
	case models.EduArticleTypeArticle:
 | 
			
		||||
		return hmnurl.BuildEducationArticle(a.Slug)
 | 
			
		||||
	case models.EduArticleTypeGlossary:
 | 
			
		||||
		return hmnurl.BuildEducationGlossary(a.Slug)
 | 
			
		||||
	default:
 | 
			
		||||
		panic("unknown education article type")
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -43,12 +43,15 @@ type editorData struct {
 | 
			
		|||
 | 
			
		||||
	// The following are filled out automatically by the
 | 
			
		||||
	// getEditorDataFor* functions.
 | 
			
		||||
	Title               string
 | 
			
		||||
	CanEditTitle        bool
 | 
			
		||||
	PostTitle           string
 | 
			
		||||
	CanEditPostTitle    bool
 | 
			
		||||
	IsEditing           bool
 | 
			
		||||
	EditInitialContents string
 | 
			
		||||
	PostReplyingTo      *templates.Post
 | 
			
		||||
	ShowEduOptions      bool
 | 
			
		||||
	PreviewClass        string
 | 
			
		||||
 | 
			
		||||
	ParserName  string
 | 
			
		||||
	MaxFileSize int
 | 
			
		||||
	UploadUrl   string
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -56,14 +59,14 @@ type editorData struct {
 | 
			
		|||
func getEditorDataForNew(urlContext *hmnurl.UrlContext, currentUser *models.User, baseData templates.BaseData, replyPost *templates.Post) editorData {
 | 
			
		||||
	result := editorData{
 | 
			
		||||
		BaseData:         baseData,
 | 
			
		||||
		CanEditTitle:   replyPost == nil,
 | 
			
		||||
		CanEditPostTitle: replyPost == nil,
 | 
			
		||||
		PostReplyingTo:   replyPost,
 | 
			
		||||
		MaxFileSize:      AssetMaxSize(currentUser),
 | 
			
		||||
		UploadUrl:        urlContext.BuildAssetUpload(),
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if replyPost != nil {
 | 
			
		||||
		result.Title = "Replying to post"
 | 
			
		||||
		result.PostTitle = "Replying to post"
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return result
 | 
			
		||||
| 
						 | 
				
			
			@ -72,8 +75,8 @@ func getEditorDataForNew(urlContext *hmnurl.UrlContext, currentUser *models.User
 | 
			
		|||
func getEditorDataForEdit(urlContext *hmnurl.UrlContext, currentUser *models.User, baseData templates.BaseData, p hmndata.PostAndStuff) editorData {
 | 
			
		||||
	return editorData{
 | 
			
		||||
		BaseData:            baseData,
 | 
			
		||||
		Title:               p.Thread.Title,
 | 
			
		||||
		CanEditTitle:        p.Thread.FirstID == p.Post.ID,
 | 
			
		||||
		PostTitle:           p.Thread.Title,
 | 
			
		||||
		CanEditPostTitle:    p.Thread.FirstID == p.Post.ID,
 | 
			
		||||
		IsEditing:           true,
 | 
			
		||||
		EditInitialContents: p.CurrentVersion.TextRaw,
 | 
			
		||||
		MaxFileSize:         AssetMaxSize(currentUser),
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,9 +0,0 @@
 | 
			
		|||
package website
 | 
			
		||||
 | 
			
		||||
func LibraryNotPortedYet(c *RequestContext) ResponseData {
 | 
			
		||||
	baseData := getBaseData(c, "Library", nil)
 | 
			
		||||
 | 
			
		||||
	var res ResponseData
 | 
			
		||||
	res.MustWriteTemplate("library_not_ported_yet.html", baseData, c.Perf)
 | 
			
		||||
	return res
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -56,7 +56,7 @@ func trackRequestPerf(h Handler) Handler {
 | 
			
		|||
}
 | 
			
		||||
 | 
			
		||||
func needsAuth(h Handler) Handler {
 | 
			
		||||
	return func(c *RequestContext) (res ResponseData) {
 | 
			
		||||
	return func(c *RequestContext) ResponseData {
 | 
			
		||||
		if c.CurrentUser == nil {
 | 
			
		||||
			return c.Redirect(hmnurl.BuildLoginPage(c.FullUrl()), http.StatusSeeOther)
 | 
			
		||||
		}
 | 
			
		||||
| 
						 | 
				
			
			@ -66,7 +66,7 @@ func needsAuth(h Handler) Handler {
 | 
			
		|||
}
 | 
			
		||||
 | 
			
		||||
func adminsOnly(h Handler) Handler {
 | 
			
		||||
	return func(c *RequestContext) (res ResponseData) {
 | 
			
		||||
	return func(c *RequestContext) ResponseData {
 | 
			
		||||
		if c.CurrentUser == nil || !c.CurrentUser.IsStaff {
 | 
			
		||||
			return FourOhFour(c)
 | 
			
		||||
		}
 | 
			
		||||
| 
						 | 
				
			
			@ -75,6 +75,16 @@ func adminsOnly(h Handler) Handler {
 | 
			
		|||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func educationAuthorsOnly(h Handler) Handler {
 | 
			
		||||
	return func(c *RequestContext) ResponseData {
 | 
			
		||||
		if c.CurrentUser == nil || !c.CurrentUser.CanAuthorEducation() {
 | 
			
		||||
			return FourOhFour(c)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		return h(c)
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func csrfMiddleware(h Handler) Handler {
 | 
			
		||||
	// CSRF mitigation actions per the OWASP cheat sheet:
 | 
			
		||||
	// https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -117,9 +117,21 @@ func NewWebsiteRoutes(conn *pgxpool.Pool) http.Handler {
 | 
			
		|||
	hmnOnly.GET(hmnurl.RegexFishbowlIndex, FishbowlIndex)
 | 
			
		||||
	hmnOnly.GET(hmnurl.RegexFishbowl, Fishbowl)
 | 
			
		||||
 | 
			
		||||
	hmnOnly.GET(hmnurl.RegexEducationIndex, EducationIndex)
 | 
			
		||||
	hmnOnly.GET(hmnurl.RegexEducationGlossary, EducationGlossary)
 | 
			
		||||
	hmnOnly.GET(hmnurl.RegexEducationArticleNew, educationAuthorsOnly(EducationArticleNew))
 | 
			
		||||
	hmnOnly.POST(hmnurl.RegexEducationArticleNew, educationAuthorsOnly(EducationArticleNewSubmit))
 | 
			
		||||
	hmnOnly.GET(hmnurl.RegexEducationArticle, EducationArticle) // Article stuff must be last so `/glossary` and others do not match as an article slug
 | 
			
		||||
	hmnOnly.GET(hmnurl.RegexEducationArticleEdit, educationAuthorsOnly(EducationArticleEdit))
 | 
			
		||||
	hmnOnly.POST(hmnurl.RegexEducationArticleEdit, educationAuthorsOnly(EducationArticleEditSubmit))
 | 
			
		||||
	hmnOnly.GET(hmnurl.RegexEducationArticleDelete, educationAuthorsOnly(EducationArticleDelete))
 | 
			
		||||
	hmnOnly.POST(hmnurl.RegexEducationArticleDelete, educationAuthorsOnly(csrfMiddleware(EducationArticleDeleteSubmit)))
 | 
			
		||||
 | 
			
		||||
	hmnOnly.POST(hmnurl.RegexAPICheckUsername, csrfMiddleware(APICheckUsername))
 | 
			
		||||
 | 
			
		||||
	hmnOnly.GET(hmnurl.RegexLibraryAny, LibraryNotPortedYet)
 | 
			
		||||
	hmnOnly.GET(hmnurl.RegexLibraryAny, func(c *RequestContext) ResponseData {
 | 
			
		||||
		return c.Redirect(hmnurl.BuildEducationIndex(), http.StatusFound)
 | 
			
		||||
	})
 | 
			
		||||
 | 
			
		||||
	// Project routes can appear either at the root (e.g. hero.handmade.network/edit)
 | 
			
		||||
	// or on a personal project path (e.g. handmade.network/p/123/hero/edit). So, we
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -28,13 +28,15 @@ func TestLogContextErrors(t *testing.T) {
 | 
			
		|||
	router := &Router{}
 | 
			
		||||
	routes := RouteBuilder{
 | 
			
		||||
		Router: router,
 | 
			
		||||
		Middleware: func(h Handler) Handler {
 | 
			
		||||
		Middlewares: []Middleware{
 | 
			
		||||
			func(h Handler) Handler {
 | 
			
		||||
				return func(c *RequestContext) (res ResponseData) {
 | 
			
		||||
					c.Logger = &logger
 | 
			
		||||
				defer logContextErrorsMiddleware(c, &res)
 | 
			
		||||
					defer logContextErrorsMiddleware(h)
 | 
			
		||||
					return h(c)
 | 
			
		||||
				}
 | 
			
		||||
			},
 | 
			
		||||
		},
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	routes.GET(regexp.MustCompile("^/test$"), func(c *RequestContext) ResponseData {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
		Loading…
	
		Reference in New Issue