Episode guide and trailing slashes in urls
This commit is contained in:
parent
b29ae69a25
commit
573fd8d2a2
|
@ -4,3 +4,6 @@ vendor/
|
||||||
dbclones/
|
dbclones/
|
||||||
coverage.out
|
coverage.out
|
||||||
public/media/
|
public/media/
|
||||||
|
cinera/*/
|
||||||
|
cinera/cinera.conf
|
||||||
|
annotations/
|
||||||
|
|
|
@ -0,0 +1,12 @@
|
||||||
|
export CINERA_REPO_PATH=/home/handmade/src/handmade-dev/cinera/Annotation-System
|
||||||
|
export CINERA_HMML_PATH=/home/handmade/src/handmade-dev/cinera/cinera_handmade.network
|
||||||
|
export CINERA_ASSETS_PATH=/home/handmade/src/handmade-dev/hmdev/static/annotations
|
||||||
|
export CINERA_OUTPUT_PATH=/home/handmade/src/handmade-dev/annotations
|
||||||
|
export DOMAIN=handmade.local
|
||||||
|
export SCHEME=https
|
||||||
|
export CINERA_MONIT_GROUP=cinera
|
||||||
|
export CINERA_SCRIPT_PATH=/home/handmade/src/handmade-dev/cinera
|
||||||
|
export ANNOTATIONS_USER=handmade
|
||||||
|
|
||||||
|
# NOTE(asaf): Known-working version as of 2021-08-26
|
||||||
|
export CINERA_VERSION=6da970d48ca2cee861b7fe2d8f4d7ed6ca9ccce1
|
|
@ -0,0 +1,74 @@
|
||||||
|
// vim:ft=c:
|
||||||
|
|
||||||
|
include = "$CINERA_HMML_PATH/cinera_includes_hero_people.conf";
|
||||||
|
include = "$CINERA_HMML_PATH/cinera_includes_riscy_people.conf";
|
||||||
|
include = "$CINERA_HMML_PATH/cinera_includes_bitwise_people.conf";
|
||||||
|
|
||||||
|
db_location = "data/cinera.db";
|
||||||
|
cache_dir = "data/cache";
|
||||||
|
|
||||||
|
ignore_privacy = "true";
|
||||||
|
hmml_dir = "$CINERA_HMML_PATH/$owner/$origin/$project";
|
||||||
|
|
||||||
|
base_dir = "$CINERA_OUTPUT_PATH/$lineage";
|
||||||
|
base_url = "$SCHEME://$origin.$DOMAIN";
|
||||||
|
|
||||||
|
assets_root_dir = "$CINERA_ASSETS_PATH";
|
||||||
|
assets_root_url = "$SCHEME://$DOMAIN/public/annotations";
|
||||||
|
default_medium = "programming";
|
||||||
|
|
||||||
|
player_location = "episode/$project";
|
||||||
|
global_theme = "global";
|
||||||
|
theme = "$origin";
|
||||||
|
|
||||||
|
// player_template = "$CINERA_SCRIPT_PATH/cinera_template_episode.html";
|
||||||
|
|
||||||
|
project = "hero" {
|
||||||
|
include = "$CINERA_HMML_PATH/cinera_includes_hero_media.conf";
|
||||||
|
owner = "cmuratori";
|
||||||
|
unit = "Day";
|
||||||
|
title = "";
|
||||||
|
|
||||||
|
project = "misc" {
|
||||||
|
unit = "";
|
||||||
|
title = "Miscellaneous";
|
||||||
|
}
|
||||||
|
project = "intro-to-c" {
|
||||||
|
title = "Introduction to C";
|
||||||
|
}
|
||||||
|
project = "code" {
|
||||||
|
title = "Handmade Hero";
|
||||||
|
}
|
||||||
|
project = "chat" {
|
||||||
|
title = "Handmade Chat";
|
||||||
|
}
|
||||||
|
project = "ray" {
|
||||||
|
title = "Handmade Ray";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
project = "riscy" {
|
||||||
|
include = "$CINERA_HMML_PATH/cinera_includes_riscy_media.conf";
|
||||||
|
base_dir = "$CINERA_OUTPUT_PATH/$origin/$project";
|
||||||
|
owner = "miotatsu";
|
||||||
|
unit = "Day";
|
||||||
|
title = "Riscy Business";
|
||||||
|
|
||||||
|
project = "risc" {
|
||||||
|
title = "Riscellaneous";
|
||||||
|
}
|
||||||
|
project = "coad" {
|
||||||
|
title = "Computer Organisation and Architecture";
|
||||||
|
}
|
||||||
|
project = "reader" {
|
||||||
|
title = "Risc-V Book Club";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
project = "bitwise" {
|
||||||
|
include = "$CINERA_HMML_PATH/cinera_includes_bitwise_media.conf";
|
||||||
|
base_dir = "$CINERA_OUTPUT_PATH/$origin/$project";
|
||||||
|
owner = "pervognsen";
|
||||||
|
unit = "Day";
|
||||||
|
title = "Bitwise";
|
||||||
|
}
|
|
@ -0,0 +1,15 @@
|
||||||
|
# How to run Cinera locally
|
||||||
|
|
||||||
|
0. Install prerequisites:
|
||||||
|
a. `libcurl4-openssl-dev`
|
||||||
|
b. `byacc`
|
||||||
|
c. `flex`
|
||||||
|
|
||||||
|
1. Copy `cinera/cinera.conf.sample` to `cinera/cinera.conf` and edit to match your local system
|
||||||
|
2. Run `cinera/user_update_cinera.sh`
|
||||||
|
3. Run `cinera/update_annotations.sh`
|
||||||
|
4. From the cinera dir run: `./run_local.sh`
|
||||||
|
5. Once it's done processing everything (you should see "Monitoring
|
||||||
|
file system for new, edited and deleted .hmml and asset files")
|
||||||
|
You can shut it down with ctrl+c.
|
||||||
|
|
|
@ -0,0 +1,9 @@
|
||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
|
[ -e "./cinera.conf" ] || exit
|
||||||
|
. ./cinera.conf
|
||||||
|
|
||||||
|
$CINERA_REPO_PATH/cinera/cinera -c cinera_hmn.conf
|
||||||
|
|
|
@ -0,0 +1,12 @@
|
||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
if [ ! -e "cinera.conf" ]; then
|
||||||
|
echo "Can't find cinera.conf"
|
||||||
|
exit
|
||||||
|
fi
|
||||||
|
. cinera.conf
|
||||||
|
|
||||||
|
./update_cinera.sh
|
||||||
|
./update_annotations.sh
|
||||||
|
|
||||||
|
[ -d "data" ] || mkdir data
|
|
@ -0,0 +1,10 @@
|
||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
|
[ -e "./cinera.conf" ] || exit
|
||||||
|
. ./cinera.conf
|
||||||
|
|
||||||
|
nohup $CINERA_REPO_PATH/cinera/cinera -c cinera_hmn.conf > data/cinera.log 2>&1 &
|
||||||
|
echo $! > data/cinera.pid
|
||||||
|
|
|
@ -0,0 +1,3 @@
|
||||||
|
#!/bin/bash
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
kill $(cat data/cinera.pid)
|
|
@ -0,0 +1,23 @@
|
||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
if [ ! -e "cinera.conf" ]; then
|
||||||
|
echo "Can't find cinera.conf"
|
||||||
|
exit
|
||||||
|
fi
|
||||||
|
. cinera.conf
|
||||||
|
|
||||||
|
if [ ! -d $CINERA_HMML_PATH ]; then
|
||||||
|
git clone git@gitssh.handmade.network:Annotation-Pushers/cinera_handmade.network.git $CINERA_HMML_PATH
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -d $CINERA_HMML_PATH ]; then
|
||||||
|
echo "Failed to clone annotation repo"
|
||||||
|
exit
|
||||||
|
fi
|
||||||
|
|
||||||
|
cd $CINERA_HMML_PATH
|
||||||
|
git pull
|
||||||
|
|
||||||
|
cp -av $CINERA_HMML_PATH/cmuratori/hero/cinera__hero.css $CINERA_ASSETS_PATH/
|
||||||
|
cp -av $CINERA_HMML_PATH/miotatsu/riscy/cinera__riscy.css $CINERA_ASSETS_PATH/
|
||||||
|
cp -av $CINERA_HMML_PATH/pervognsen/bitwise/cinera__bitwise.css $CINERA_ASSETS_PATH/
|
|
@ -0,0 +1,15 @@
|
||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
if [ ! -e "cinera.conf" ]; then
|
||||||
|
echo "Can't find cinera.conf"
|
||||||
|
exit
|
||||||
|
fi
|
||||||
|
. cinera.conf
|
||||||
|
|
||||||
|
monit -g $CINERA_MONIT_GROUP stop
|
||||||
|
|
||||||
|
CMD="cd $CINERA_SCRIPT_PATH; ./user_update_cinera.sh"
|
||||||
|
|
||||||
|
su - $ANNOTATIONS_USER -c "$CMD"
|
||||||
|
|
||||||
|
monit -g $CINERA_MONIT_GROUP start
|
|
@ -0,0 +1,36 @@
|
||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
if [ ! -e "cinera.conf" ]; then
|
||||||
|
echo "Can't find cinera.conf"
|
||||||
|
exit
|
||||||
|
fi
|
||||||
|
. cinera.conf
|
||||||
|
|
||||||
|
if [ ! -d $CINERA_REPO_PATH ]; then
|
||||||
|
git clone git@gitssh.handmade.network:Annotation-Pushers/Annotation-System.git $CINERA_REPO_PATH
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -d $CINERA_REPO_PATH ]; then
|
||||||
|
echo "Failed to clone cinera"
|
||||||
|
exit
|
||||||
|
fi
|
||||||
|
|
||||||
|
|
||||||
|
cd $CINERA_REPO_PATH
|
||||||
|
git pull
|
||||||
|
if [[ -z "${CINERA_VERSION}" ]]; then
|
||||||
|
git checkout master
|
||||||
|
else
|
||||||
|
git checkout $CINERA_VERSION
|
||||||
|
fi
|
||||||
|
cd $CINERA_REPO_PATH/hmmlib
|
||||||
|
make
|
||||||
|
cp hmml.a hmmlib.h ../cinera
|
||||||
|
cd $CINERA_REPO_PATH/cinera
|
||||||
|
`$SHELL cinera.c`
|
||||||
|
|
||||||
|
if [ ! -d $CINERA_ASSETS_PATH ]; then
|
||||||
|
mkdir $CINERA_ASSETS_PATH
|
||||||
|
fi
|
||||||
|
|
||||||
|
cp -av $CINERA_REPO_PATH/cinera/*.{css,js,png} $CINERA_ASSETS_PATH
|
|
@ -0,0 +1,54 @@
|
||||||
|
SET DAEMON 5
|
||||||
|
SET LOGFILE /var/log/monit.log
|
||||||
|
SET STATEFILE /var/lib/monit/state
|
||||||
|
SET HTTPD UNIXSOCKET /var/run/monit.sock
|
||||||
|
allow user:pass
|
||||||
|
SET MAILSERVER
|
||||||
|
box.handmadedev.org
|
||||||
|
PORT 587
|
||||||
|
USERNAME "noreply@handmadedev.org"
|
||||||
|
PASSWORD "[FILL THIS IN]"
|
||||||
|
USING tlsv1
|
||||||
|
SET MAIL-FORMAT {
|
||||||
|
from: noreply@handmadedev.org
|
||||||
|
reply-to: noreply@handmadedev.org
|
||||||
|
subject: $SERVICE $EVENT at $DATE
|
||||||
|
message: Monit $ACTION $SERVICE at $DATE on $HOST: $DESCRIPTION
|
||||||
|
}
|
||||||
|
SET ALERT team@handmadedev.org only on { nonexist, instance }
|
||||||
|
|
||||||
|
CHECK PROCESS beta_cinera PIDFILE /home/hmn-beta/srv/cinera/data/cinera.pid
|
||||||
|
GROUP cinera_beta
|
||||||
|
START PROGRAM = "/home/hmn-beta/srv/cinera/start.sh" AS UID "annotations"
|
||||||
|
STOP PROGRAM = "/home/hmn-beta/srv/cinera/stop.sh" AS UID "annotations"
|
||||||
|
MODE PASSIVE
|
||||||
|
|
||||||
|
CHECK PROCESS live_cinera PIDFILE /home/hmn-live/srv/cinera/data/cinera.pid
|
||||||
|
GROUP cinera_live
|
||||||
|
START PROGRAM = "/home/hmn-live/srv/cinera/start.sh" AS UID "annotations"
|
||||||
|
STOP PROGRAM = "/home/hmn-live/srv/cinera/stop.sh" AS UID "annotations"
|
||||||
|
MODE PASSIVE
|
||||||
|
|
||||||
|
CHECK PROCESS beta_discord_history PIDFILE /home/hmn-beta/discordhistory.pid
|
||||||
|
GROUP discord_history_beta
|
||||||
|
START PROGRAM = "/home/hmn-beta/start_discord_history.sh" AS UID "hmn-beta"
|
||||||
|
STOP PROGRAM = "/bin/bash -c '/bin/kill `/bin/cat /home/hmn-beta/discordhistory.pid`'"
|
||||||
|
MODE ACTIVE
|
||||||
|
|
||||||
|
CHECK PROCESS beta_discord_bot PIDFILE /home/hmn-beta/discordbot.pid
|
||||||
|
GROUP discord_bot_beta
|
||||||
|
START PROGRAM = "/home/hmn-beta/start_discord_bot.sh" AS UID "hmn-beta"
|
||||||
|
STOP PROGRAM = "/bin/bash -c '/bin/kill `/bin/cat /home/hmn-beta/discordbot.pid`'"
|
||||||
|
MODE ACTIVE
|
||||||
|
|
||||||
|
CHECK PROCESS live_discord_history PIDFILE /home/hmn-live/discordhistory.pid
|
||||||
|
GROUP discord_history_live
|
||||||
|
START PROGRAM = "/home/hmn-live/start_discord_history.sh" AS UID "hmn-live"
|
||||||
|
STOP PROGRAM = "/bin/bash -c '/bin/kill `/bin/cat /home/hmn-live/discordhistory.pid`'"
|
||||||
|
MODE ACTIVE
|
||||||
|
|
||||||
|
CHECK PROCESS live_discord_bot PIDFILE /home/hmn-live/discordbot.pid
|
||||||
|
GROUP discord_bot_live
|
||||||
|
START PROGRAM = "/home/hmn-live/start_discord_bot.sh" AS UID "hmn-live"
|
||||||
|
STOP PROGRAM = "/bin/bash -c '/bin/kill `/bin/cat /home/hmn-live/discordbot.pid`'"
|
||||||
|
MODE ACTIVE
|
|
@ -42,4 +42,8 @@ var Config = HMNConfig{
|
||||||
AssetsPathPrefix: "",
|
AssetsPathPrefix: "",
|
||||||
AssetsPublicUrlRoot: "",
|
AssetsPublicUrlRoot: "",
|
||||||
},
|
},
|
||||||
|
EpisodeGuide: EpisodeGuide{
|
||||||
|
CineraOutputPath: "./annotations/",
|
||||||
|
Projects: map[string]string{"hero": "code", "riscy": "riscy", "bitwise": "bitwise"},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
|
@ -26,6 +26,7 @@ type HMNConfig struct {
|
||||||
Email EmailConfig
|
Email EmailConfig
|
||||||
DigitalOcean DigitalOceanConfig
|
DigitalOcean DigitalOceanConfig
|
||||||
Discord DiscordConfig
|
Discord DiscordConfig
|
||||||
|
EpisodeGuide EpisodeGuide
|
||||||
}
|
}
|
||||||
|
|
||||||
type PostgresConfig struct {
|
type PostgresConfig struct {
|
||||||
|
@ -76,6 +77,11 @@ type DiscordConfig struct {
|
||||||
LibraryChannelID string
|
LibraryChannelID string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type EpisodeGuide struct {
|
||||||
|
CineraOutputPath string
|
||||||
|
Projects map[string]string // NOTE(asaf): Maps from slugs to default episode guide topic
|
||||||
|
}
|
||||||
|
|
||||||
func (info PostgresConfig) DSN() string {
|
func (info PostgresConfig) DSN() string {
|
||||||
return fmt.Sprintf("user=%s password=%s host=%s port=%d dbname=%s", info.User, info.Password, info.Hostname, info.Port, info.DbName)
|
return fmt.Sprintf("user=%s password=%s host=%s port=%d dbname=%s", info.User, info.Password, info.Hostname, info.Port, info.DbName)
|
||||||
}
|
}
|
||||||
|
|
|
@ -276,6 +276,18 @@ func TestLibraryResource(t *testing.T) {
|
||||||
AssertSubdomain(t, BuildLibraryResource("hero", 1), "hero")
|
AssertSubdomain(t, BuildLibraryResource("hero", 1), "hero")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestEpisodeGuide(t *testing.T) {
|
||||||
|
AssertRegexMatch(t, BuildEpisodeList("hero", ""), RegexEpisodeList, map[string]string{"topic": ""})
|
||||||
|
AssertRegexMatch(t, BuildEpisodeList("hero", "code"), RegexEpisodeList, map[string]string{"topic": "code"})
|
||||||
|
AssertSubdomain(t, BuildEpisodeList("hero", "code"), "hero")
|
||||||
|
|
||||||
|
AssertRegexMatch(t, BuildEpisode("hero", "code", "day001"), RegexEpisode, map[string]string{"topic": "code", "episode": "day001"})
|
||||||
|
AssertSubdomain(t, BuildEpisode("hero", "code", "day001"), "hero")
|
||||||
|
|
||||||
|
AssertRegexMatch(t, BuildCineraIndex("hero", "code"), RegexCineraIndex, map[string]string{"topic": "code"})
|
||||||
|
AssertSubdomain(t, BuildCineraIndex("hero", "code"), "hero")
|
||||||
|
}
|
||||||
|
|
||||||
func TestProjectCSS(t *testing.T) {
|
func TestProjectCSS(t *testing.T) {
|
||||||
AssertRegexMatch(t, BuildProjectCSS("000000"), RegexProjectCSS, nil)
|
AssertRegexMatch(t, BuildProjectCSS("000000"), RegexProjectCSS, nil)
|
||||||
}
|
}
|
||||||
|
|
|
@ -554,6 +554,38 @@ func BuildLibraryResource(projectSlug string, resourceId int) string {
|
||||||
return ProjectUrl(builder.String(), nil, projectSlug)
|
return ProjectUrl(builder.String(), nil, projectSlug)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Episode Guide
|
||||||
|
*/
|
||||||
|
|
||||||
|
var RegexEpisodeList = regexp.MustCompile(`^/episode(/(?P<topic>[^/]+))?$`)
|
||||||
|
|
||||||
|
func BuildEpisodeList(projectSlug string, topic string) string {
|
||||||
|
defer CatchPanic()
|
||||||
|
|
||||||
|
var builder strings.Builder
|
||||||
|
builder.WriteString("/episode")
|
||||||
|
if topic != "" {
|
||||||
|
builder.WriteString("/")
|
||||||
|
builder.WriteString(topic)
|
||||||
|
}
|
||||||
|
return ProjectUrl(builder.String(), nil, projectSlug)
|
||||||
|
}
|
||||||
|
|
||||||
|
var RegexEpisode = regexp.MustCompile(`^/episode/(?P<topic>[^/]+)/(?P<episode>[^/]+)$`)
|
||||||
|
|
||||||
|
func BuildEpisode(projectSlug string, topic string, episode string) string {
|
||||||
|
defer CatchPanic()
|
||||||
|
return ProjectUrl(fmt.Sprintf("/episode/%s/%s", topic, episode), nil, projectSlug)
|
||||||
|
}
|
||||||
|
|
||||||
|
var RegexCineraIndex = regexp.MustCompile(`^/(?P<topic>[^/]+).index$`)
|
||||||
|
|
||||||
|
func BuildCineraIndex(projectSlug string, topic string) string {
|
||||||
|
defer CatchPanic()
|
||||||
|
return ProjectUrl(fmt.Sprintf("/%s.index", topic), nil, projectSlug)
|
||||||
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Discord OAuth
|
* Discord OAuth
|
||||||
*/
|
*/
|
||||||
|
|
|
@ -0,0 +1,16 @@
|
||||||
|
{{ template "base.html" . }}
|
||||||
|
|
||||||
|
{{ define "extrahead" }}
|
||||||
|
<link rel="stylesheet" type="text/css" href="{{ static "annotations/cinera.css" }}">
|
||||||
|
<link rel="stylesheet" type="text/css" href="{{ static (strjoin "annotations/cinera__" .Project.Subdomain ".css") }}">
|
||||||
|
<link rel="stylesheet" type="text/css" href="{{ static "annotations/cinera_topics.css" }}">
|
||||||
|
<script type="text/javascript" src="{{ static "annotations/cinera_pre.js" }}"></script>
|
||||||
|
<script type="text/javascript" src="{{ static "annotations/cinera_post.js" }}" defer></script>
|
||||||
|
<script type="text/javascript" src="{{ static "annotations/cinera_player_pre.js" }}"></script>
|
||||||
|
{{ end }}
|
||||||
|
|
||||||
|
{{ define "content" }}
|
||||||
|
<div class="content-block">
|
||||||
|
{{ .Content }}
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
|
@ -0,0 +1,111 @@
|
||||||
|
{{ template "base.html" . }}
|
||||||
|
|
||||||
|
{{ define "extrahead" }}
|
||||||
|
<style type="text/css">
|
||||||
|
.queryContainer {
|
||||||
|
width: 80%;
|
||||||
|
margin: 15px auto 20px auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: horizontal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queryContainer input {
|
||||||
|
height:3em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queryContainer #query {
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queryContainer #submit_button {
|
||||||
|
flex-grow: 0;
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding:0px 10px;
|
||||||
|
vertical-align:middle;
|
||||||
|
margin:0px;
|
||||||
|
}
|
||||||
|
#annotationContainer {
|
||||||
|
left: -10px;
|
||||||
|
margin: 0px;
|
||||||
|
margin-left: -20px;
|
||||||
|
padding: 0px;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border: 0px transparent none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<link rel="stylesheet" type="text/css" href="{{ static "annotations/cinera.css" }}">
|
||||||
|
<link rel="stylesheet" type="text/css" href="{{ static (strjoin "annotations/cinera__" .Project.Subdomain ".css") }}">
|
||||||
|
<link rel="stylesheet" type="text/css" href="{{ static "annotations/cinera_topics.css" }}">
|
||||||
|
<script type="text/javascript" src="{{ static "annotations/cinera_pre.js" }}"></script>
|
||||||
|
<script type="text/javascript" src="{{ static "annotations/cinera_post.js" }}" defer></script>
|
||||||
|
{{ end }}
|
||||||
|
|
||||||
|
{{ define "content" }}
|
||||||
|
<div class="flex flex-column flex-row-l">
|
||||||
|
<div class="flex-grow-1 overflow-hidden">
|
||||||
|
<h2>Annotations for the topic: {{ .CurrentTopic }}</h2>
|
||||||
|
{{ .Content }}
|
||||||
|
</div>
|
||||||
|
<div class="sidebar flex-shrink-0 mw6 w-30-l self-center self-start-l mh3 mh0-ns ml3-l overflow-hidden">
|
||||||
|
<div class="content-block box logo">
|
||||||
|
<img alt="{{ .Project.Name }}" src="{{ .Project.Logo }}">
|
||||||
|
</div>
|
||||||
|
<div class="content-block subprojects">
|
||||||
|
<strong>Also have a look at these {{ .Project.Name }} annotations:</strong>
|
||||||
|
<ul>
|
||||||
|
{{ range .Topics }}
|
||||||
|
{{ if .Url }}
|
||||||
|
<li class="ttc">
|
||||||
|
<a href="{{ .Url }}">{{ .LinkText }}</a>
|
||||||
|
</li>
|
||||||
|
{{ else }}
|
||||||
|
<li class="ttc"><strong>{{ .LinkText }}</strong></li>
|
||||||
|
{{ end }}
|
||||||
|
{{ end }}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{{ if eq .Project.Subdomain "hero" }}
|
||||||
|
<div class="content-block donate">
|
||||||
|
<br/>
|
||||||
|
<strong>Preorder the Game, Get the Source!</strong>
|
||||||
|
<p>Visit <a target="_blank" href="https://handmadehero.org">https://handmadehero.org</a> for more information about Handmade Hero</p>
|
||||||
|
<br/>
|
||||||
|
<strong>Support the Episode Guide</strong>
|
||||||
|
<p><strong><a href="https://handmade.network/m/Miblo">Miblo</a></strong> is currently the sole maintainer of this episode guide. You can see him working live on
|
||||||
|
<a class="external" href="http://www.twitch.tv/miblo" target="_blank">Twitch</a>.</p>
|
||||||
|
</div>
|
||||||
|
<div class="content-block">
|
||||||
|
<br/>
|
||||||
|
<p><strong>Credits</strong></p>
|
||||||
|
<p style="text-align: left; ">
|
||||||
|
The detailed episode guides linked to on this page would also not have been possible without the generous historical contributions of time and energy from:
|
||||||
|
<ul style="text-align: left;">
|
||||||
|
<li>Jace Bennett (<a href="https://twitter.com/jacebennett" target="_blank">@jacebennett</a>),</li>
|
||||||
|
<li>Abner Coimbre (<a href="https://twitter.com/AbnerCoimbre" target="_blank">@AbnerCoimbre</a>),</li>
|
||||||
|
<li>Matthew VanDevander (<a href="https://twitter.com/mvandevander" target="_blank">@mvandevander</a></li>
|
||||||
|
<li>Gustavo Chávez (<a href="https://twitter.com/gusChvz" target="_blank">@gusChvz</a>),</li>
|
||||||
|
<li>Kasper Sauramo (<a href="https://twitter.com/KMSchme" target="_blank">@KMSchme</a>),</li>
|
||||||
|
<li>Ben Craddock (<a href="https://twitter.com/theinternetftw" target="_blank">@theinternetftw</a>),</li>
|
||||||
|
<li>Dustin Specht (<a href="https://twitter.com/Dustin_Specht" target="_blank">@Dustin_Specht</a>),</li>
|
||||||
|
<li>Jacob Pike (<a href="https://twitter.com/pikejd" target="_blank">@pikejd</a>),</li>
|
||||||
|
<li>Matt Mascarenhas (<a href="https://twitter.com/miblo_" target="_blank">@miblo_</a>),</li>
|
||||||
|
<li>Miguel Lechón (<a href="https://twitter.com/debiatan" target="_blank">@debiatan</a>),</li>
|
||||||
|
<li>Cory Henderlite (<a href="https://twitter.com/effect0r" target="_blank">@effect0r</a>),</li>
|
||||||
|
<li>Insofaras (<a href="https://twitter.com/insofaras_" target="_blank">@insofaras_</a>),</li>
|
||||||
|
<li>Clay Murray (<a href="https://twitter.com/powerc9000" target="_blank">@powerc9000</a>) and</li>
|
||||||
|
<li>Tim Liou (<a href="https://twitter.com/wdliou" target="_blank">@wdliou</a>).</li>
|
||||||
|
</ul>
|
||||||
|
</p>
|
||||||
|
<p>Please take a moment to thank our contributors if you happen to see them on The Twitters!</p>
|
||||||
|
</div>
|
||||||
|
{{ else if eq .Project.Subdomain "riscy" }}
|
||||||
|
<div class="content-block donate">
|
||||||
|
<strong>Support the Episode Guide</strong>
|
||||||
|
<p><strong><a href="https://handmade.network/m/Miblo">Miblo</a></strong> is currently the sole maintainer of this episode guide. You can see him working live on
|
||||||
|
<a class="external" href="http://www.twitch.tv/miblo" target="_blank">Twitch</a>.</p>
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
|
@ -58,7 +58,7 @@
|
||||||
<a href="{{ .Header.ManifestoUrl }}" class="misson">Mission</a>
|
<a href="{{ .Header.ManifestoUrl }}" class="misson">Mission</a>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
{{/* {% if project.default_annotation_category %} */}}
|
{{/* {% if project.default_annotation_category %} */}}
|
||||||
{{ if false }}
|
{{ if .Header.EpisodeGuideUrl }}
|
||||||
<a href="{{ .Header.EpisodeGuideUrl }}" class="annotations">Episode Guide</a>
|
<a href="{{ .Header.EpisodeGuideUrl }}" class="annotations">Episode Guide</a>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
{{ if .Header.EditUrl }}
|
{{ if .Header.EditUrl }}
|
||||||
|
|
|
@ -105,6 +105,9 @@ var HMNTemplateFuncs = template.FuncMap{
|
||||||
}
|
}
|
||||||
return a
|
return a
|
||||||
},
|
},
|
||||||
|
"strjoin": func(strs ...string) string {
|
||||||
|
return strings.Join(strs, "")
|
||||||
|
},
|
||||||
"absolutedate": func(t time.Time) string {
|
"absolutedate": func(t time.Time) string {
|
||||||
return t.UTC().Format("January 2, 2006, 3:04pm")
|
return t.UTC().Format("January 2, 2006, 3:04pm")
|
||||||
},
|
},
|
||||||
|
|
|
@ -0,0 +1,197 @@
|
||||||
|
package website
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"html/template"
|
||||||
|
"io/fs"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.handmade.network/hmn/hmn/src/config"
|
||||||
|
"git.handmade.network/hmn/hmn/src/hmnurl"
|
||||||
|
"git.handmade.network/hmn/hmn/src/templates"
|
||||||
|
)
|
||||||
|
|
||||||
|
func CineraIndex(c *RequestContext) ResponseData {
|
||||||
|
topic := c.PathParams["topic"]
|
||||||
|
slug := c.CurrentProject.Slug
|
||||||
|
|
||||||
|
_, foundTopic := topicsForProject(slug, topic)
|
||||||
|
if foundTopic == "" {
|
||||||
|
return FourOhFour(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
indexPath := path.Join(config.Config.EpisodeGuide.CineraOutputPath, slug, foundTopic, fmt.Sprintf("%s.index", foundTopic))
|
||||||
|
|
||||||
|
content, err := os.ReadFile(indexPath)
|
||||||
|
if err != nil {
|
||||||
|
return FourOhFour(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
var res ResponseData
|
||||||
|
res.Write(content)
|
||||||
|
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
var episodeListRegex = regexp.MustCompile(`(?ism)^.*<body>(?P<guide>.*)</body>.*$`)
|
||||||
|
|
||||||
|
type EpisodeListData struct {
|
||||||
|
templates.BaseData
|
||||||
|
Content template.HTML
|
||||||
|
CurrentTopic string
|
||||||
|
Topics []templates.Link
|
||||||
|
}
|
||||||
|
|
||||||
|
func EpisodeList(c *RequestContext) ResponseData {
|
||||||
|
topic := c.PathParams["topic"]
|
||||||
|
slug := c.CurrentProject.Slug
|
||||||
|
|
||||||
|
defaultTopic, hasEpisodeGuide := config.Config.EpisodeGuide.Projects[slug]
|
||||||
|
|
||||||
|
if !hasEpisodeGuide {
|
||||||
|
return c.Redirect(hmnurl.BuildProjectHomepage(slug), http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
if topic == "" {
|
||||||
|
return c.Redirect(hmnurl.BuildEpisodeList(slug, defaultTopic), http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
allTopics, foundTopic := topicsForProject(slug, topic)
|
||||||
|
if foundTopic == "" {
|
||||||
|
return FourOhFour(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
htmlPath := path.Join(config.Config.EpisodeGuide.CineraOutputPath, slug, foundTopic, "index.html")
|
||||||
|
|
||||||
|
htmlContent, err := os.ReadFile(htmlPath)
|
||||||
|
if err != nil {
|
||||||
|
return FourOhFour(c)
|
||||||
|
}
|
||||||
|
matches := episodeListRegex.FindStringSubmatch(string(htmlContent))
|
||||||
|
if matches == nil || matches[episodeListRegex.SubexpIndex("guide")] == "" {
|
||||||
|
c.Logger.Error().Str("Filename", htmlPath).Msg("Episode guide index.html can't be parsed.")
|
||||||
|
return FourOhFour(c)
|
||||||
|
}
|
||||||
|
guide := matches[episodeListRegex.SubexpIndex("guide")]
|
||||||
|
|
||||||
|
var topicLinks []templates.Link
|
||||||
|
for _, t := range allTopics {
|
||||||
|
url := ""
|
||||||
|
if t != foundTopic {
|
||||||
|
url = hmnurl.BuildEpisodeList(slug, t)
|
||||||
|
}
|
||||||
|
topicLinks = append(topicLinks, templates.Link{LinkText: t, Url: url})
|
||||||
|
}
|
||||||
|
|
||||||
|
var res ResponseData
|
||||||
|
baseData := getBaseData(c)
|
||||||
|
res.MustWriteTemplate("episode_list.html", EpisodeListData{
|
||||||
|
BaseData: baseData,
|
||||||
|
Content: template.HTML(guide),
|
||||||
|
CurrentTopic: foundTopic,
|
||||||
|
Topics: topicLinks,
|
||||||
|
}, c.Perf)
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
var episodeTitleRegex = regexp.MustCompile(`(?ism)<span class="episode_name">(?P<title>.*?)</span>`)
|
||||||
|
var episodeContentRegex = regexp.MustCompile(`(?ism)<body>(?P<content>.*)</body>`)
|
||||||
|
|
||||||
|
type EpisodeData struct {
|
||||||
|
templates.BaseData
|
||||||
|
Content template.HTML
|
||||||
|
}
|
||||||
|
|
||||||
|
func Episode(c *RequestContext) ResponseData {
|
||||||
|
episode := c.PathParams["episode"]
|
||||||
|
topic := c.PathParams["topic"]
|
||||||
|
slug := c.CurrentProject.Slug
|
||||||
|
|
||||||
|
_, hasEpisodeGuide := config.Config.EpisodeGuide.Projects[slug]
|
||||||
|
|
||||||
|
if !hasEpisodeGuide {
|
||||||
|
return c.Redirect(hmnurl.BuildProjectHomepage(slug), http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, foundTopic := topicsForProject(slug, topic)
|
||||||
|
if foundTopic == "" {
|
||||||
|
return FourOhFour(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
foundEpisode := findEpisode(slug, foundTopic, episode)
|
||||||
|
if foundEpisode == "" {
|
||||||
|
return FourOhFour(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
htmlPath := path.Join(config.Config.EpisodeGuide.CineraOutputPath, slug, foundTopic, "episode", foundTopic, foundEpisode, "index.html")
|
||||||
|
htmlContent, err := os.ReadFile(htmlPath)
|
||||||
|
if err != nil {
|
||||||
|
return FourOhFour(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
titleMatches := episodeTitleRegex.FindStringSubmatch(string(htmlContent))
|
||||||
|
title := fmt.Sprintf("%s Episode Guide", c.CurrentProject.Name)
|
||||||
|
if titleMatches != nil && titleMatches[episodeTitleRegex.SubexpIndex("title")] != "" {
|
||||||
|
title = fmt.Sprintf("%s | %s", titleMatches[episodeTitleRegex.SubexpIndex("title")], title)
|
||||||
|
}
|
||||||
|
|
||||||
|
contentMatches := episodeContentRegex.FindStringSubmatch(string(htmlContent))
|
||||||
|
if contentMatches == nil || contentMatches[episodeContentRegex.SubexpIndex("content")] == "" {
|
||||||
|
c.Logger.Error().Str("filename", htmlPath).Msg("Episode file can't be parsed.")
|
||||||
|
return FourOhFour(c)
|
||||||
|
}
|
||||||
|
content := contentMatches[episodeContentRegex.SubexpIndex("content")]
|
||||||
|
|
||||||
|
var res ResponseData
|
||||||
|
baseData := getBaseData(c)
|
||||||
|
baseData.Title = title
|
||||||
|
res.MustWriteTemplate("episode.html", EpisodeData{
|
||||||
|
BaseData: baseData,
|
||||||
|
Content: template.HTML(content),
|
||||||
|
}, c.Perf)
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
func topicsForProject(projectSlug string, requestedTopic string) ([]string, string) {
|
||||||
|
searchPath := path.Join(config.Config.EpisodeGuide.CineraOutputPath, projectSlug)
|
||||||
|
entries, err := fs.ReadDir(os.DirFS(searchPath), ".")
|
||||||
|
if err != nil {
|
||||||
|
return nil, ""
|
||||||
|
}
|
||||||
|
var allTopics []string
|
||||||
|
foundTopic := ""
|
||||||
|
for _, entry := range entries {
|
||||||
|
if entry.IsDir() {
|
||||||
|
t := entry.Name()
|
||||||
|
allTopics = append(allTopics, t)
|
||||||
|
if strings.ToLower(t) == strings.ToLower(requestedTopic) {
|
||||||
|
foundTopic = t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return allTopics, foundTopic
|
||||||
|
}
|
||||||
|
|
||||||
|
// NOTE(asaf): Assuming topic is valid. Please verify before calling.
|
||||||
|
func findEpisode(projectSlug string, topic string, requestedEpisode string) string {
|
||||||
|
searchPath := path.Join(config.Config.EpisodeGuide.CineraOutputPath, projectSlug, topic, "episode", topic) // NOTE(asaf): Yes. We have `topic` twice in the path.
|
||||||
|
entries, err := fs.ReadDir(os.DirFS(searchPath), ".")
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
for _, entry := range entries {
|
||||||
|
if entry.IsDir() {
|
||||||
|
episode := entry.Name()
|
||||||
|
if strings.ToLower(episode) == strings.ToLower(requestedEpisode) {
|
||||||
|
return episode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ""
|
||||||
|
}
|
|
@ -72,6 +72,11 @@ func (r *Router) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
path = strings.TrimSuffix(path, "/")
|
||||||
|
if path == "" {
|
||||||
|
path = "/"
|
||||||
|
}
|
||||||
|
|
||||||
match := route.Regex.FindStringSubmatch(path)
|
match := route.Regex.FindStringSubmatch(path)
|
||||||
if match == nil {
|
if match == nil {
|
||||||
continue
|
continue
|
||||||
|
|
|
@ -192,6 +192,13 @@ func NewWebsiteRoutes(longRequestContext context.Context, conn *pgxpool.Pool, pe
|
||||||
mainRoutes.GET(hmnurl.RegexUserProfile, UserProfile)
|
mainRoutes.GET(hmnurl.RegexUserProfile, UserProfile)
|
||||||
mainRoutes.GET(hmnurl.RegexProjectNotApproved, ProjectHomepage)
|
mainRoutes.GET(hmnurl.RegexProjectNotApproved, ProjectHomepage)
|
||||||
|
|
||||||
|
mainRoutes.GET(hmnurl.RegexDiscordOAuthCallback, authMiddleware(DiscordOAuthCallback))
|
||||||
|
mainRoutes.POST(hmnurl.RegexDiscordUnlink, authMiddleware(csrfMiddleware(DiscordUnlink)))
|
||||||
|
mainRoutes.POST(hmnurl.RegexDiscordShowcaseBacklog, authMiddleware(csrfMiddleware(DiscordShowcaseBacklog)))
|
||||||
|
|
||||||
|
mainRoutes.GET(hmnurl.RegexUserSettings, authMiddleware(UserSettings))
|
||||||
|
mainRoutes.POST(hmnurl.RegexUserSettings, authMiddleware(csrfMiddleware(UserSettingsSave)))
|
||||||
|
|
||||||
// NOTE(asaf): Any-project routes:
|
// NOTE(asaf): Any-project routes:
|
||||||
mainRoutes.GET(hmnurl.RegexForumNewThread, authMiddleware(ForumNewThread))
|
mainRoutes.GET(hmnurl.RegexForumNewThread, authMiddleware(ForumNewThread))
|
||||||
mainRoutes.POST(hmnurl.RegexForumNewThreadSubmit, authMiddleware(csrfMiddleware(ForumNewThreadSubmit)))
|
mainRoutes.POST(hmnurl.RegexForumNewThreadSubmit, authMiddleware(csrfMiddleware(ForumNewThreadSubmit)))
|
||||||
|
@ -228,12 +235,9 @@ func NewWebsiteRoutes(longRequestContext context.Context, conn *pgxpool.Pool, pe
|
||||||
mainRoutes.GET(hmnurl.RegexPodcastEpisode, PodcastEpisode)
|
mainRoutes.GET(hmnurl.RegexPodcastEpisode, PodcastEpisode)
|
||||||
mainRoutes.GET(hmnurl.RegexPodcastRSS, PodcastRSS)
|
mainRoutes.GET(hmnurl.RegexPodcastRSS, PodcastRSS)
|
||||||
|
|
||||||
mainRoutes.GET(hmnurl.RegexDiscordOAuthCallback, authMiddleware(DiscordOAuthCallback))
|
mainRoutes.GET(hmnurl.RegexEpisodeList, EpisodeList)
|
||||||
mainRoutes.POST(hmnurl.RegexDiscordUnlink, authMiddleware(csrfMiddleware(DiscordUnlink)))
|
mainRoutes.GET(hmnurl.RegexEpisode, Episode)
|
||||||
mainRoutes.POST(hmnurl.RegexDiscordShowcaseBacklog, authMiddleware(csrfMiddleware(DiscordShowcaseBacklog)))
|
mainRoutes.GET(hmnurl.RegexCineraIndex, CineraIndex)
|
||||||
|
|
||||||
mainRoutes.GET(hmnurl.RegexUserSettings, authMiddleware(UserSettings))
|
|
||||||
mainRoutes.POST(hmnurl.RegexUserSettings, authMiddleware(csrfMiddleware(UserSettingsSave)))
|
|
||||||
|
|
||||||
mainRoutes.GET(hmnurl.RegexProjectCSS, ProjectCSS)
|
mainRoutes.GET(hmnurl.RegexProjectCSS, ProjectCSS)
|
||||||
mainRoutes.GET(hmnurl.RegexEditorPreviewsJS, func(c *RequestContext) ResponseData {
|
mainRoutes.GET(hmnurl.RegexEditorPreviewsJS, func(c *RequestContext) ResponseData {
|
||||||
|
@ -261,6 +265,12 @@ func getBaseData(c *RequestContext) templates.BaseData {
|
||||||
|
|
||||||
notices := getNoticesFromCookie(c)
|
notices := getNoticesFromCookie(c)
|
||||||
|
|
||||||
|
episodeGuideUrl := ""
|
||||||
|
defaultTopic, hasAnnotations := config.Config.EpisodeGuide.Projects[c.CurrentProject.Slug]
|
||||||
|
if hasAnnotations {
|
||||||
|
episodeGuideUrl = hmnurl.BuildEpisodeList(c.CurrentProject.Slug, defaultTopic)
|
||||||
|
}
|
||||||
|
|
||||||
return templates.BaseData{
|
return templates.BaseData{
|
||||||
Theme: c.Theme,
|
Theme: c.Theme,
|
||||||
|
|
||||||
|
@ -289,7 +299,7 @@ func getBaseData(c *RequestContext) templates.BaseData {
|
||||||
ForumsUrl: hmnurl.BuildForum(c.CurrentProject.Slug, nil, 1),
|
ForumsUrl: hmnurl.BuildForum(c.CurrentProject.Slug, nil, 1),
|
||||||
LibraryUrl: hmnurl.BuildLibrary(c.CurrentProject.Slug),
|
LibraryUrl: hmnurl.BuildLibrary(c.CurrentProject.Slug),
|
||||||
ManifestoUrl: hmnurl.BuildManifesto(),
|
ManifestoUrl: hmnurl.BuildManifesto(),
|
||||||
EpisodeGuideUrl: hmnurl.BuildHomepage(), // TODO(asaf)
|
EpisodeGuideUrl: episodeGuideUrl,
|
||||||
EditUrl: "",
|
EditUrl: "",
|
||||||
SearchActionUrl: "https://duckduckgo.com",
|
SearchActionUrl: "https://duckduckgo.com",
|
||||||
},
|
},
|
||||||
|
|
Loading…
Reference in New Issue