Episode guide and trailing slashes in urls

This commit is contained in:
Asaf Gartner 2021-08-28 13:40:13 +03:00
parent b29ae69a25
commit 573fd8d2a2
23 changed files with 670 additions and 8 deletions

3
.gitignore vendored
View File

@ -4,3 +4,6 @@ vendor/
dbclones/
coverage.out
public/media/
cinera/*/
cinera/cinera.conf
annotations/

12
cinera/cinera.conf.sample Executable file
View File

@ -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

74
cinera/cinera_hmn.conf Executable file
View File

@ -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";
}

View File

@ -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.

9
cinera/run_local.sh Executable file
View File

@ -0,0 +1,9 @@
#!/bin/bash
cd "$(dirname "$0")"
[ -e "./cinera.conf" ] || exit
. ./cinera.conf
$CINERA_REPO_PATH/cinera/cinera -c cinera_hmn.conf

12
cinera/setup.sh Executable file
View File

@ -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

10
cinera/start.sh Executable file
View File

@ -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

3
cinera/stop.sh Executable file
View File

@ -0,0 +1,3 @@
#!/bin/bash
cd "$(dirname "$0")"
kill $(cat data/cinera.pid)

23
cinera/update_annotations.sh Executable file
View File

@ -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/

15
cinera/update_cinera.sh Executable file
View File

@ -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

36
cinera/user_update_cinera.sh Executable file
View File

@ -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

54
monitrc.sample Normal file
View File

@ -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

View File

@ -42,4 +42,8 @@ var Config = HMNConfig{
AssetsPathPrefix: "",
AssetsPublicUrlRoot: "",
},
EpisodeGuide: EpisodeGuide{
CineraOutputPath: "./annotations/",
Projects: map[string]string{"hero": "code", "riscy": "riscy", "bitwise": "bitwise"},
},
}

View File

@ -26,6 +26,7 @@ type HMNConfig struct {
Email EmailConfig
DigitalOcean DigitalOceanConfig
Discord DiscordConfig
EpisodeGuide EpisodeGuide
}
type PostgresConfig struct {
@ -76,6 +77,11 @@ type DiscordConfig struct {
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 {
return fmt.Sprintf("user=%s password=%s host=%s port=%d dbname=%s", info.User, info.Password, info.Hostname, info.Port, info.DbName)
}

View File

@ -276,6 +276,18 @@ func TestLibraryResource(t *testing.T) {
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) {
AssertRegexMatch(t, BuildProjectCSS("000000"), RegexProjectCSS, nil)
}

View File

@ -554,6 +554,38 @@ func BuildLibraryResource(projectSlug string, resourceId int) string {
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
*/

View File

@ -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 }}

View File

@ -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&aacute;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&oacute;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 }}

View File

@ -58,7 +58,7 @@
<a href="{{ .Header.ManifestoUrl }}" class="misson">Mission</a>
{{ end }}
{{/* {% if project.default_annotation_category %} */}}
{{ if false }}
{{ if .Header.EpisodeGuideUrl }}
<a href="{{ .Header.EpisodeGuideUrl }}" class="annotations">Episode Guide</a>
{{ end }}
{{ if .Header.EditUrl }}

View File

@ -105,6 +105,9 @@ var HMNTemplateFuncs = template.FuncMap{
}
return a
},
"strjoin": func(strs ...string) string {
return strings.Join(strs, "")
},
"absolutedate": func(t time.Time) string {
return t.UTC().Format("January 2, 2006, 3:04pm")
},

View File

@ -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 ""
}

View File

@ -72,6 +72,11 @@ func (r *Router) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
continue
}
path = strings.TrimSuffix(path, "/")
if path == "" {
path = "/"
}
match := route.Regex.FindStringSubmatch(path)
if match == nil {
continue

View File

@ -192,6 +192,13 @@ func NewWebsiteRoutes(longRequestContext context.Context, conn *pgxpool.Pool, pe
mainRoutes.GET(hmnurl.RegexUserProfile, UserProfile)
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:
mainRoutes.GET(hmnurl.RegexForumNewThread, authMiddleware(ForumNewThread))
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.RegexPodcastRSS, PodcastRSS)
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)))
mainRoutes.GET(hmnurl.RegexEpisodeList, EpisodeList)
mainRoutes.GET(hmnurl.RegexEpisode, Episode)
mainRoutes.GET(hmnurl.RegexCineraIndex, CineraIndex)
mainRoutes.GET(hmnurl.RegexProjectCSS, ProjectCSS)
mainRoutes.GET(hmnurl.RegexEditorPreviewsJS, func(c *RequestContext) ResponseData {
@ -261,6 +265,12 @@ func getBaseData(c *RequestContext) templates.BaseData {
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{
Theme: c.Theme,
@ -289,7 +299,7 @@ func getBaseData(c *RequestContext) templates.BaseData {
ForumsUrl: hmnurl.BuildForum(c.CurrentProject.Slug, nil, 1),
LibraryUrl: hmnurl.BuildLibrary(c.CurrentProject.Slug),
ManifestoUrl: hmnurl.BuildManifesto(),
EpisodeGuideUrl: hmnurl.BuildHomepage(), // TODO(asaf)
EpisodeGuideUrl: episodeGuideUrl,
EditUrl: "",
SearchActionUrl: "https://duckduckgo.com",
},