Started working on the frontend

- Added the new post frontend
- Hooked up publishing of posts to the site publisher
- Added an site exporter as a publishing target
This commit is contained in:
Leon Mika 2026-02-21 10:22:10 +11:00
parent a59008b3e8
commit e77cac2fd5
40 changed files with 1427 additions and 84 deletions

46
.air.toml Normal file
View file

@ -0,0 +1,46 @@
root = "."
testdata_dir = "testdata"
tmp_dir = "tmp"
[build]
args_bin = []
bin = "./build/weiro"
cmd = "make build"
delay = 1000
exclude_dir = ["static", "build", "node_modules"]
exclude_file = []
exclude_regex = ["_test.go"]
exclude_unchanged = false
follow_symlink = false
full_bin = ""
include_dir = []
include_ext = ["go", "tpl", "tmpl", "html", "css", "js"]
include_file = []
kill_delay = "0s"
log = "build-errors.log"
poll = false
poll_interval = 0
post_cmd = []
pre_cmd = []
rerun = false
rerun_delay = 500
send_interrupt = false
stop_on_error = false
[color]
app = ""
build = "yellow"
main = "magenta"
runner = "green"
watcher = "cyan"
[log]
main_only = false
time = false
[misc]
clean_on_exit = true
[screen]
clear_on_rebuild = false
keep_scroll = true

2
.gitignore vendored
View file

@ -1,4 +1,6 @@
build/
.idea/
node_modules/
static/assets/
# Local Netlify folder
.netlify

View file

@ -2,10 +2,27 @@ BUILD_DIR=build
all: clean build
.Phony: init
init:
npm install --save-exact --save-dev esbuild
.Phony: clean
clean:
-rm -r $(BUILD_DIR)
.Phony: frontend
frontend:
npm install
npx esbuild --bundle ./assets/css/main.css --outfile=./static/assets/main.css
.Phony: gen
gen:
go run github.com/sqlc-dev/sqlc/cmd/sqlc@v1.30.0 generate
.Phony: build
build: frontend
go build -o ./build/weiro
.Phony: run
run: build
./build/weiro

View file

@ -1,7 +1,10 @@
---
date: 2026-02-18T22:17:00+11:00
id: atHn1bZa7Z0D
title: First Post
date: 2026-02-18T11:17:00Z
tags: []
slug: /2026/02/18/first-post
---
Hello World!

View file

@ -1,7 +1,10 @@
---
date: 2026-02-19T22:17:00+11:00
id: ANRTjDSaCZcj
title: About a DB
date: 2026-02-19T11:17:00Z
tags: []
slug: /2026/02/19/about-a-db
---
Hello again.

View file

@ -0,0 +1,8 @@
---
id: XFpDGhCLRSiV
title: Another Publish Tests
date: 2026-02-20T22:51:32Z
tags: []
slug: /2026/02/21/another-publish-tests
---
Okay, that wasn't as smooth sailing as I was hoping for. I forgot to set the publish key so Netlify refused to publish the site. Let see how this attempt goes.

View file

@ -0,0 +1,8 @@
---
id: yvLBWj26Zsgp
title: Export As Publish Target
date: 2026-02-20T23:17:26Z
tags: []
slug: /2026/02/21/export-as-publish
---
I've added a site exporter which will export the site as a series of Markdown files whenever I publish a post. With any luck, both the exporter and the Netlify publisher will work when I publish this. This will save the posts I've made so far, while allowing me to init the database without having to save new migrations.

View file

@ -0,0 +1,14 @@
---
id: ARFyO2TiZ0Ju
title: First Attempt at Posting From the UI
date: 2026-02-20T22:48:58Z
tags: []
slug: /2026/02/21/first-attempt-at
---
If you're seeing this, then posting from the UI works.
The UI is using service-side HTTP rendering, so there's nothing fancy going on here. I've chosen to use Boostrap for the frontend. It's a little traditional but hey, it looks good. I can't post a screenshot of the UI as Weiro doesn't have attachments yet, but I will once I have attachments working.
The new post should have been saved in the Sqlite3 database, which should trigger a rebuild of the site and a publish to Netlify. The existing posts should still be there too. At the moment, the publishing is done inline, which won't be the case for long, but I want to make sure the publishing flow is working properly.
Okay, here we go.

View file

@ -1,8 +1,13 @@
---
date: 2026-02-20T17:36:00+11:00
id: Go4SilAxyruR
title: Direct Publish To Netlify
date: 2026-02-20T06:36:00Z
tags: []
slug: /2026/02/20/netlify
---
Just a quick one right now. Integrated the Netlify client allowing direct publish to Netlify.
Previous attempts were using the Netlify CLI, but after learning that Go has a Netlify client,
I have zero use for that now.
I think we're ready to start building the UI.

View file

@ -0,0 +1,13 @@
---
id: f-mK1xJDUlUg
title: Success!
date: 2026-02-20T22:59:18Z
tags: []
slug: /2026/02/21/success
---
Okay, publishing from the frontend works.
Of course now I need to make sure all these posts are kept, as I would like to prevent a publish every time I make a change. So either enable a local preview mode or have a way to export the posts I save: both possible with the multiple publishing targets. And I want to be able to easily save posts as drafts, and also allow for local saves should the updated post be unable to reach the server.
Basically, I want to make sure the writing and publishing flow is as good as it can be, otherwise I wouldn't be comfortable using it. And for any blogging CMS, having a way to write posts and be comfortable that your words won't be lost is paramount.

View file

@ -1,6 +1,3 @@
title: Weiro
tagline: A blogging CMS
base_url: https://jolly-boba-9e2486.netlify.app/
public:
netlify:
site_id: 55c878a7-189e-42cf-aa02-5c60908143f3
base_url: https://jolly-boba-9e2486.netlify.app

15
assets/css/main.css Normal file
View file

@ -0,0 +1,15 @@
@import "bootstrap/dist/css/bootstrap.css";
.post-form {
display: grid;
grid-template-rows: min-content auto min-content;
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
}
.post-form textarea {
height: 100%;
}

23
go.mod
View file

@ -1,6 +1,6 @@
module lmika.dev/lmika/weiro
go 1.24.3
go 1.25.0
require (
emperror.dev/errors v0.8.1
@ -20,6 +20,7 @@ require (
github.com/Azure/go-autorest/tracing v0.5.0 // indirect
github.com/PuerkitoBio/purell v1.1.1 // indirect
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 // indirect
github.com/andybalholm/brotli v1.2.0 // indirect
github.com/asaskevich/govalidator v0.0.0-20200907205600-7a23bdc65eef // indirect
github.com/cenkalti/backoff/v4 v4.0.2 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
@ -36,27 +37,41 @@ require (
github.com/go-openapi/swag v0.19.12 // indirect
github.com/go-openapi/validate v0.20.0 // indirect
github.com/go-stack/stack v1.8.0 // indirect
github.com/gofiber/fiber/v3 v3.0.0 // indirect
github.com/gofiber/schema v1.6.0 // indirect
github.com/gofiber/template v1.8.3 // indirect
github.com/gofiber/template/html/v3 v3.0.2 // indirect
github.com/gofiber/template/v2 v2.1.0 // indirect
github.com/gofiber/utils/v2 v2.0.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/klauspost/compress v1.18.3 // indirect
github.com/konsorten/go-windows-terminal-sequences v1.0.3 // indirect
github.com/lmika/gopkgs v0.0.0-20240408110817-a02f6fc67d1f // indirect
github.com/mailru/easyjson v0.7.6 // indirect
github.com/matoous/go-nanoid/v2 v2.1.0 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mitchellh/mapstructure v1.4.0 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/netlify/open-api/v2 v2.49.1 // indirect
github.com/philhofer/fwd v1.2.0 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rsc/goversion v1.2.0 // indirect
github.com/sirupsen/logrus v1.6.0 // indirect
github.com/tinylib/msgp v1.6.3 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasthttp v1.69.0 // indirect
go.mongodb.org/mongo-driver v1.4.4 // indirect
go.uber.org/atomic v1.7.0 // indirect
go.uber.org/multierr v1.6.0 // indirect
golang.org/x/crypto v0.47.0 // indirect
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
golang.org/x/net v0.7.0 // indirect
golang.org/x/sys v0.37.0 // indirect
golang.org/x/text v0.7.0 // indirect
golang.org/x/net v0.49.0 // indirect
golang.org/x/sys v0.40.0 // indirect
golang.org/x/text v0.33.0 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
modernc.org/libc v1.67.6 // indirect
modernc.org/mathutil v1.7.1 // indirect

37
go.sum
View file

@ -43,6 +43,8 @@ github.com/agnivade/levenshtein v1.0.1/go.mod h1:CURSv5d9Uaml+FovSIICkLbAUZ9S4Rq
github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8=
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o=
github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY=
github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8=
@ -203,6 +205,18 @@ github.com/gobuffalo/packd v0.1.0/go.mod h1:M2Juc+hhDXf/PnmBANFCqx4DM3wRbgDvnVWe
github.com/gobuffalo/packr/v2 v2.0.9/go.mod h1:emmyGweYTm6Kdper+iywB6YK5YzuKchGtJQZ0Odn4pQ=
github.com/gobuffalo/packr/v2 v2.2.0/go.mod h1:CaAwI0GPIAv+5wKLtv8Afwl+Cm78K/I/VCm/3ptBN+0=
github.com/gobuffalo/syncx v0.0.0-20190224160051-33c29581e754/go.mod h1:HhnNqWY95UYwwW3uSASeV7vtgYkT2t16hJgV3AEPUpw=
github.com/gofiber/fiber/v3 v3.0.0 h1:GPeCG8X60L42wLKrzgeewDHBr6pE6veAvwaXsqD3Xjk=
github.com/gofiber/fiber/v3 v3.0.0/go.mod h1:kVZiO/AwyT5Pq6PgC8qRCJ+j/BHrMy5jNw1O9yH38aY=
github.com/gofiber/schema v1.6.0 h1:rAgVDFwhndtC+hgV7Vu5ItQCn7eC2mBA4Eu1/ZTiEYY=
github.com/gofiber/schema v1.6.0/go.mod h1:WNZWpQx8LlPSK7ZaX0OqOh+nQo/eW2OevsXs1VZfs/s=
github.com/gofiber/template v1.8.3 h1:hzHdvMwMo/T2kouz2pPCA0zGiLCeMnoGsQZBTSYgZxc=
github.com/gofiber/template v1.8.3/go.mod h1:bs/2n0pSNPOkRa5VJ8zTIvedcI/lEYxzV3+YPXdBvq8=
github.com/gofiber/template/html/v3 v3.0.2 h1:/Fh8UcEsB4uhf1QWNbYaAOwXxSORebJ2zXkb5tgG/TI=
github.com/gofiber/template/html/v3 v3.0.2/go.mod h1:9phaCZLPZq2nFNTZj9zrmR8FSA8ydtBQFL9SEsr4jqI=
github.com/gofiber/template/v2 v2.1.0 h1:vrLY6uEW2HdioJm6J5FGUpYZuapVQhHciNz21XQjR/4=
github.com/gofiber/template/v2 v2.1.0/go.mod h1:ohgpR/Ng90nJbK+IyNzrgR/XpnBNt862/oTF5G7SAmE=
github.com/gofiber/utils/v2 v2.0.0 h1:SCC3rpsEDWupFSHtc0RKxg/BKgV0s1qKfZg9Jv6D0sM=
github.com/gofiber/utils/v2 v2.0.0/go.mod h1:xF9v89FfmbrYqI/bQUGN7gR8ZtXot2jxnZvmAUtiavE=
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
@ -286,6 +300,8 @@ github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvW
github.com/kisielk/errcheck v1.2.0/go.mod h1:/BMXB+zMLi60iA8Vv6Ksmxu/1UDYcXs4uQLJ+jE2L00=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/klauspost/compress v1.9.5/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A=
github.com/klauspost/compress v1.18.3 h1:9PJRvfbmTabkOX8moIpXPbMMbYN60bWImDDU7L+/6zw=
github.com/klauspost/compress v1.18.3/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/konsorten/go-windows-terminal-sequences v1.0.3 h1:CE8S1cTafDpPvMhIxNJKvHsGVBgn1xWYf1NbHQhywc8=
@ -311,7 +327,11 @@ github.com/mailru/easyjson v0.7.6 h1:8yTIVnZgCoiM1TgqoeTl+LfU5Jg6/xL3QhGQnimLYnA
github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/markbates/oncer v0.0.0-20181203154359-bf2de49a0be2/go.mod h1:Ld9puTsIW75CHf65OeIOkyKbteujpZVXDpWK6YGZbxE=
github.com/markbates/safe v1.0.1/go.mod h1:nAqgmRi7cY2nqMc92/bSEeQA+R4OheNU2T1kNSCBdG0=
github.com/matoous/go-nanoid/v2 v2.1.0 h1:P64+dmq21hhWdtvZfEAofnvJULaRR1Yib0+PnU669bE=
github.com/matoous/go-nanoid/v2 v2.1.0/go.mod h1:KlbGNQ+FhrUNIHUxZdL63t7tl4LaPkZNpUULS8H4uVM=
github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
@ -344,6 +364,8 @@ github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/9
github.com/pelletier/go-toml v1.4.0/go.mod h1:PN7xzY2wHTK0K9p34ErDQMlFxa51Fk0OUruD3k1mMwo=
github.com/pelletier/go-toml v1.7.0/go.mod h1:vwGMzjaWMwyfHwgIBhI2YUM4fB6nL6lVAvS1LBMMhTE=
github.com/pelletier/go-toml v1.8.0/go.mod h1:D6yutnOGMveHEPV7VQOuvI/gXY61bv+9bAOTRnLElKs=
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
@ -409,8 +431,14 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw=
github.com/tidwall/pretty v1.0.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk=
github.com/tinylib/msgp v1.6.3 h1:bCSxiTz386UTgyT1i0MSCvdbWjVW+8sG3PjkGsZQt4s=
github.com/tinylib/msgp v1.6.3/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U=
github.com/toqueteos/webbrowser v1.2.0/go.mod h1:XWoZq4cyp9WeUeak7w7LXRUQf1F1ATJMir8RTqb4ayM=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasthttp v1.69.0 h1:fNLLESD2SooWeh2cidsuFtOcrEi4uB4m1mPrkJMZyVI=
github.com/valyala/fasthttp v1.69.0/go.mod h1:4wA4PfAraPlAsJ5jMSqCE2ug5tqUPwKXxVj8oNECGcw=
github.com/vektah/gqlparser v1.1.2/go.mod h1:1ycwN7Ij5njmMkPPAOaRFY4rET2Enx7IkVv3vaXspKw=
github.com/xdg/scram v0.0.0-20180814205039-7eeb5667e42c/go.mod h1:lB8K/P019DLNhemzwFU4jHLhdvlE6uDZjXFejJXr49I=
github.com/xdg/stringprep v0.0.0-20180714160509-73f8eece6fdc/go.mod h1:Jhud4/sHMO4oL310DaZAKk9ZaJ08SJfe+sJh0HrGL1Y=
@ -451,6 +479,8 @@ golang.org/x/crypto v0.0.0-20191206172530-e9b2fee46413/go.mod h1:LzIPMQfyMNhhGPh
golang.org/x/crypto v0.0.0-20200604202706-70a84ac30bf9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
@ -503,6 +533,8 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.7.0 h1:rJrUqqhjsgNp7KqAIc25s9pZnjU7TUcSY7HcVZjdn1g=
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
@ -548,6 +580,8 @@ golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
@ -559,6 +593,8 @@ golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0 h1:4BRB4x83lYWy72KwLD/qYDuTu7q9PjSagHvijDw7cLo=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
@ -592,6 +628,7 @@ golang.org/x/tools v0.0.0-20200612220849-54c614fe050c/go.mod h1:EkVYQZoAsY45+roY
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=

View file

@ -1,4 +1,4 @@
package sitereader
package importexport
import (
"time"
@ -11,13 +11,13 @@ type ReadSiteModels struct {
Posts []*models.Post
}
type siteMeta struct {
type Site struct {
Title string `yaml:"title"`
Tagline string `yaml:"tagline"`
BaseURL string `yaml:"base_url"`
}
type postMeta struct {
type Post struct {
ID string `yaml:"id"`
Title string `yaml:"title"`
Date time.Time `yaml:"date"`

View file

@ -0,0 +1,41 @@
package middleware
import (
"strconv"
"github.com/gofiber/fiber/v3"
"lmika.dev/lmika/weiro/models"
"lmika.dev/lmika/weiro/providers/db"
)
func RequiresSite(db *db.Provider) func(c fiber.Ctx) error {
return func(c fiber.Ctx) error {
siteIDStr := c.Params("siteID")
if siteIDStr == "" {
return fiber.ErrBadRequest
}
siteID, err := strconv.ParseInt(siteIDStr, 10, 64)
if err != nil {
return fiber.ErrBadRequest
}
user, ok := models.GetUser(c.Context())
if !ok {
return fiber.ErrUnauthorized
}
site, err := db.SelectSiteByID(c.Context(), siteID)
if err != nil {
return fiber.ErrNotFound
}
if site.OwnerID != user.ID {
return fiber.ErrForbidden
}
c.Locals("site", site)
c.SetContext(models.WithSite(c.Context(), site))
return c.Next()
}
}

View file

@ -0,0 +1,24 @@
package middleware
import (
"log"
"github.com/gofiber/fiber/v3"
"lmika.dev/lmika/weiro/models"
)
func AuthUser() func(c fiber.Ctx) error {
return func(c fiber.Ctx) error {
// TEMP - Actually do the auth here
user := models.User{
ID: 1,
Username: "testuser",
}
c.Locals("user", user)
c.SetContext(models.WithUser(c.Context(), user))
log.Printf("User %s authenticated", user.Username)
return c.Next()
}
}

37
handlers/posts.go Normal file
View file

@ -0,0 +1,37 @@
package handlers
import (
"fmt"
"github.com/gofiber/fiber/v3"
"lmika.dev/lmika/weiro/models"
"lmika.dev/lmika/weiro/services/posts"
)
type PostsHandler struct {
PostService *posts.Service
}
func (ph PostsHandler) Index(c fiber.Ctx) error {
return c.Render("posts/index", fiber.Map{})
}
func (ph PostsHandler) New(c fiber.Ctx) error {
return c.Render("posts/new", fiber.Map{
"guid": models.NewNanoID(),
})
}
func (ph PostsHandler) Update(c fiber.Ctx) error {
var req posts.CreatePostParams
if err := c.Bind().Body(&req); err != nil {
return err
}
post, err := ph.PostService.PublishPost(c.Context(), req)
if err != nil {
return err
}
return c.Redirect().To(fmt.Sprintf("/sites/%v/posts", post.SiteID))
}

74
main.go
View file

@ -1,15 +1,16 @@
package main
import (
"context"
"log"
"os"
"lmika.dev/lmika/weiro/models"
"github.com/gofiber/fiber/v3"
"github.com/gofiber/fiber/v3/middleware/static"
"github.com/gofiber/template/html/v3"
"lmika.dev/lmika/weiro/handlers"
"lmika.dev/lmika/weiro/handlers/middleware"
"lmika.dev/lmika/weiro/providers/db"
"lmika.dev/lmika/weiro/services/importer"
"lmika.dev/lmika/weiro/services/posts"
"lmika.dev/lmika/weiro/services/publisher"
_ "modernc.org/sqlite"
)
@ -20,23 +21,47 @@ func main() {
}
defer dbp.Close()
user, err := dbp.SelectUserByUsername(context.Background(), "testuser")
if err != nil {
user = models.User{
Username: "testuser",
PasswordHashed: []byte("changeme"),
}
if err := dbp.SaveUser(context.Background(), &user); err != nil {
log.Fatal(err)
}
}
importerSvc := importer.New(dbp)
publisherSvc := publisher.New(dbp)
ctx := models.WithUser(context.Background(), user)
postService := posts.New(dbp, publisherSvc)
site, err := importerSvc.Import(ctx, "_test-site")
//user, err := dbp.SelectUserByUsername(context.Background(), "testuser")
//if err != nil {
// user = models.User{
// Username: "testuser",
// PasswordHashed: []byte("changeme"),
// }
// if err := dbp.SaveUser(context.Background(), &user); err != nil {
// log.Fatal(err)
// }
//}
app := fiber.New(fiber.Config{
Views: html.New("./views", ".html"),
ViewsLayout: "layouts/main",
PassLocalsToViews: true,
})
siteGroup := app.Group("/sites/:siteID", middleware.AuthUser(), middleware.RequiresSite(dbp))
ph := handlers.PostsHandler{PostService: postService}
siteGroup.Get("/posts", ph.Index)
siteGroup.Get("/posts/new", ph.New)
siteGroup.Post("/posts", ph.Update)
app.Get("/", func(c fiber.Ctx) error {
return c.Redirect().To("/sites/1/posts")
})
app.Get("/static/*", static.New("./static"))
// TEMP
//
/*
dbp.SaveUser(context.Background(), &models.User{Username: "testuser"})
ctx := models.WithUser(context.Background(), models.User{ID: 1})
site, err := importer.New(dbp).Import(ctx, "_test-site")
if err != nil {
log.Fatal(err)
}
@ -48,13 +73,14 @@ func main() {
TargetRef: "55c878a7-189e-42cf-aa02-5c60908143f3",
TargetKey: os.Getenv("NETLIFY_AUTH_TOKEN"),
}
if err := dbp.SavePublishTarget(ctx, &target); err != nil {
log.Fatal(err)
}
*/
//if err := publisherSvc.Publish(ctx, site.ID); err != nil {
// log.Fatal(err)
//}
if err := publisherSvc.Publish(ctx, site.ID); err != nil {
log.Fatal(err)
}
log.Println("Done")
log.Fatal(app.Listen(":3000"))
}

View file

@ -3,8 +3,10 @@ package models
import "context"
type userKeyType struct{}
type siteKeyType struct{}
var userKey = userKeyType{}
var siteKey = userKeyType{}
func WithUser(ctx context.Context, user User) context.Context {
return context.WithValue(ctx, userKey, user)
@ -14,3 +16,12 @@ func GetUser(ctx context.Context) (User, bool) {
user, ok := ctx.Value(userKey).(User)
return user, ok
}
func WithSite(ctx context.Context, site Site) context.Context {
return context.WithValue(ctx, siteKey, site)
}
func GetSite(ctx context.Context) (Site, bool) {
site, ok := ctx.Value(siteKey).(Site)
return site, ok
}

View file

@ -4,3 +4,5 @@ import "emperror.dev/errors"
var UserRequiredError = errors.New("user required")
var PermissionError = errors.New("permission denied")
var NotFoundError = errors.New("not found")
var SiteRequiredError = errors.New("site required")

8
models/ids.go Normal file
View file

@ -0,0 +1,8 @@
package models
import "github.com/matoous/go-nanoid/v2"
func NewNanoID() string {
id, _ := gonanoid.New(12)
return id
}

34
models/ids_test.go Normal file
View file

@ -0,0 +1,34 @@
package models
import (
"testing"
)
func TestNewNanoID(t *testing.T) {
id := NewNanoID()
if len(id) != 12 {
t.Errorf("Expected ID length of 12, got %d", len(id))
}
if id == "" {
t.Error("Expected non-empty ID")
}
}
func TestNewNanoID_Uniqueness(t *testing.T) {
ids := make(map[string]bool)
iterations := 1000
for i := 0; i < iterations; i++ {
id := NewNanoID()
if ids[id] {
t.Errorf("Duplicate ID generated: %s", id)
}
ids[id] = true
}
if len(ids) != iterations {
t.Errorf("Expected %d unique IDs, got %d", iterations, len(ids))
}
}

View file

@ -1,6 +1,12 @@
package models
import "time"
import (
"bufio"
"fmt"
"strings"
"time"
"unicode"
)
type Post struct {
ID int64
@ -12,3 +18,61 @@ type Post struct {
CreatedAt time.Time
PublishedAt time.Time
}
func (p *Post) BestSlug() string {
if p.Slug != "" {
return p.Slug
}
bestDateToUse := p.PublishedAt
if bestDateToUse.IsZero() {
bestDateToUse = p.CreatedAt
}
slugPath := firstNWords(p.Title, 3, wordForSlug)
if slugPath == "" {
slugPath = firstNWords(p.Body, 3, wordForSlug)
}
if slugPath != "" {
slugPath = strings.Replace(strings.ToLower(slugPath), " ", "-", -1)
} else {
slugPath = p.GUID
if slugPath == "" {
slugPath = bestDateToUse.Format("150405")
}
}
datePart := fmt.Sprintf("%04d/%02d/%02d", bestDateToUse.Year(), bestDateToUse.Month(), bestDateToUse.Day())
return fmt.Sprintf("/%s/%s", datePart, slugPath)
}
func wordForSlug(word string) string {
var sb strings.Builder
for _, c := range word {
if unicode.IsLetter(c) || unicode.IsNumber(c) {
sb.WriteRune(c)
}
}
return sb.String()
}
func firstNWords(s string, n int, keepWord func(word string) string) string {
if n == 0 {
return ""
}
suitableWords := make([]string, 0, n)
scnr := bufio.NewScanner(strings.NewReader(s))
scnr.Split(bufio.ScanWords)
for scnr.Scan() {
word := scnr.Text()
if w := keepWord(word); w != "" {
suitableWords = append(suitableWords, w)
if len(suitableWords) >= n {
break
}
}
}
return strings.Join(suitableWords, " ")
}

78
models/posts_test.go Normal file
View file

@ -0,0 +1,78 @@
package models
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
)
func TestFirstNWords(t *testing.T) {
tests := []struct {
words int
input string
expected string
}{
{words: 3, input: "This is a test string with multiple words", expected: "This is a"},
{words: 5, input: "Short string", expected: "Short string"},
{words: 0, input: "Empty string", expected: ""},
{words: 3, input: " The rain in Spain etc.", expected: "The rain in"},
{words: 3, input: " The? rain! in$ Spain etc.", expected: "The rain in"},
{words: 3, input: " !!! The 23123 rain ++_+_+ in Spain etc.", expected: "The 23123 rain"},
}
for _, test := range tests {
t.Run(test.input, func(t *testing.T) {
result := firstNWords(test.input, test.words, wordForSlug)
assert.Equal(t, test.expected, result)
})
}
}
func TestPost_BestSlug(t *testing.T) {
postDate := time.Date(2023, time.January, 1, 15, 12, 11, 0, time.UTC)
tests := []struct {
name string
post Post
expected string
}{
{
name: "returns slug when slug is set",
post: Post{Slug: "my-custom-slug", Title: "My Title", PublishedAt: postDate},
expected: "my-custom-slug",
},
{
name: "use title when slug is empty",
post: Post{Slug: "", Title: "My Title", PublishedAt: postDate},
expected: "/2023/01/01/my-title",
},
{
name: "use body when slug is empty",
post: Post{Slug: "", Body: "My body", PublishedAt: postDate},
expected: "/2023/01/01/my-body",
},
{
name: "use guid when body is empty",
post: Post{GUID: "abc123", PublishedAt: postDate},
expected: "/2023/01/01/abc123",
},
{
name: "use time component when guid is empty",
post: Post{Slug: "", Title: "", PublishedAt: postDate},
expected: "/2023/01/01/151211",
},
{
name: "use created date if publish date is unset",
post: Post{Slug: "", Title: "a title", CreatedAt: postDate},
expected: "/2023/01/01/a-title",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
result := test.post.BestSlug()
assert.Equal(t, test.expected, result)
})
}
}

529
package-lock.json generated Normal file
View file

@ -0,0 +1,529 @@
{
"name": "weiro",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"dependencies": {
"bootstrap": "^5.3.8"
},
"devDependencies": {
"esbuild": "0.27.3"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz",
"integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz",
"integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz",
"integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz",
"integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz",
"integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz",
"integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz",
"integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz",
"integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz",
"integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz",
"integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz",
"integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz",
"integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz",
"integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz",
"integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz",
"integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz",
"integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz",
"integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz",
"integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz",
"integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz",
"integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz",
"integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openharmony-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz",
"integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz",
"integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz",
"integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz",
"integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz",
"integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@popperjs/core": {
"version": "2.11.8",
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
"integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==",
"license": "MIT",
"peer": true,
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/popperjs"
}
},
"node_modules/bootstrap": {
"version": "5.3.8",
"resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.8.tgz",
"integrity": "sha512-HP1SZDqaLDPwsNiqRqi5NcP0SSXciX2s9E+RyqJIIqGo+vJeN5AJVM98CXmW/Wux0nQ5L7jeWUdplCEf0Ee+tg==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/twbs"
},
{
"type": "opencollective",
"url": "https://opencollective.com/bootstrap"
}
],
"license": "MIT",
"peerDependencies": {
"@popperjs/core": "^2.11.8"
}
},
"node_modules/esbuild": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz",
"integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.27.3",
"@esbuild/android-arm": "0.27.3",
"@esbuild/android-arm64": "0.27.3",
"@esbuild/android-x64": "0.27.3",
"@esbuild/darwin-arm64": "0.27.3",
"@esbuild/darwin-x64": "0.27.3",
"@esbuild/freebsd-arm64": "0.27.3",
"@esbuild/freebsd-x64": "0.27.3",
"@esbuild/linux-arm": "0.27.3",
"@esbuild/linux-arm64": "0.27.3",
"@esbuild/linux-ia32": "0.27.3",
"@esbuild/linux-loong64": "0.27.3",
"@esbuild/linux-mips64el": "0.27.3",
"@esbuild/linux-ppc64": "0.27.3",
"@esbuild/linux-riscv64": "0.27.3",
"@esbuild/linux-s390x": "0.27.3",
"@esbuild/linux-x64": "0.27.3",
"@esbuild/netbsd-arm64": "0.27.3",
"@esbuild/netbsd-x64": "0.27.3",
"@esbuild/openbsd-arm64": "0.27.3",
"@esbuild/openbsd-x64": "0.27.3",
"@esbuild/openharmony-arm64": "0.27.3",
"@esbuild/sunos-x64": "0.27.3",
"@esbuild/win32-arm64": "0.27.3",
"@esbuild/win32-ia32": "0.27.3",
"@esbuild/win32-x64": "0.27.3"
}
}
}
}

8
package.json Normal file
View file

@ -0,0 +1,8 @@
{
"devDependencies": {
"esbuild": "0.27.3"
},
"dependencies": {
"bootstrap": "^5.3.8"
}
}

11
providers/db/errors.go Normal file
View file

@ -0,0 +1,11 @@
package db
import (
"database/sql"
"emperror.dev/errors"
)
func ErrorIsNoRows(err error) bool {
return errors.Is(err, sql.ErrNoRows)
}

View file

@ -47,6 +47,26 @@ func (q *Queries) InsertPost(ctx context.Context, arg InsertPostParams) (int64,
return id, err
}
const selectPostByGUID = `-- name: SelectPostByGUID :one
SELECT id, site_id, guid, title, body, slug, created_at, published_at FROM posts WHERE guid = ? LIMIT 1
`
func (q *Queries) SelectPostByGUID(ctx context.Context, guid string) (Post, error) {
row := q.db.QueryRowContext(ctx, selectPostByGUID, guid)
var i Post
err := row.Scan(
&i.ID,
&i.SiteID,
&i.Guid,
&i.Title,
&i.Body,
&i.Slug,
&i.CreatedAt,
&i.PublishedAt,
)
return i, err
}
const selectPostsOfSite = `-- name: SelectPostsOfSite :many
SELECT id, site_id, guid, title, body, slug, created_at, published_at FROM posts WHERE site_id = ? ORDER BY created_at DESC LIMIT 10
`
@ -82,3 +102,31 @@ func (q *Queries) SelectPostsOfSite(ctx context.Context, siteID int64) ([]Post,
}
return items, nil
}
const updatePost = `-- name: UpdatePost :exec
UPDATE posts SET
title = ?,
body = ?,
slug = ?,
published_at = ?
WHERE id = ?
`
type UpdatePostParams struct {
Title string
Body string
Slug string
PublishedAt int64
ID int64
}
func (q *Queries) UpdatePost(ctx context.Context, arg UpdatePostParams) error {
_, err := q.db.ExecContext(ctx, updatePost,
arg.Title,
arg.Body,
arg.Slug,
arg.PublishedAt,
arg.ID,
)
return err
}

View file

@ -16,20 +16,20 @@ func (db *Provider) SelectPostsOfSite(ctx context.Context, siteID int64) ([]*mod
posts := make([]*models.Post, len(rows))
for i, row := range rows {
posts[i] = &models.Post{
ID: row.ID,
SiteID: row.SiteID,
GUID: row.Guid,
Title: row.Title,
Body: row.Body,
Slug: row.Slug,
CreatedAt: time.Unix(row.CreatedAt, 0).UTC(),
PublishedAt: time.Unix(row.PublishedAt, 0).UTC(),
}
posts[i] = dbPostToPost(row)
}
return posts, nil
}
func (db *Provider) SelectPostByGUID(ctx context.Context, guid string) (*models.Post, error) {
row, err := db.queries.SelectPostByGUID(ctx, guid)
if err != nil {
return nil, err
}
return dbPostToPost(row), nil
}
func (db *Provider) SavePost(ctx context.Context, post *models.Post) error {
if post.ID == 0 {
newID, err := db.queries.InsertPost(ctx, sqlgen.InsertPostParams{
@ -48,6 +48,24 @@ func (db *Provider) SavePost(ctx context.Context, post *models.Post) error {
return nil
}
// No update query defined in sqlgen yet
return nil
return db.queries.UpdatePost(ctx, sqlgen.UpdatePostParams{
ID: post.ID,
Title: post.Title,
Body: post.Body,
Slug: post.Slug,
PublishedAt: post.PublishedAt.Unix(),
})
}
func dbPostToPost(row sqlgen.Post) *models.Post {
return &models.Post{
ID: row.ID,
SiteID: row.SiteID,
GUID: row.Guid,
Title: row.Title,
Body: row.Body,
Slug: row.Slug,
CreatedAt: time.Unix(row.CreatedAt, 0).UTC(),
PublishedAt: time.Unix(row.PublishedAt, 0).UTC(),
}
}

View file

@ -0,0 +1,80 @@
package siteexporter
import (
"fmt"
"os"
"path/filepath"
"gopkg.in/yaml.v3"
"lmika.dev/lmika/weiro/handlers/importexport"
"lmika.dev/lmika/weiro/models"
)
type SiteExporter struct {
site models.Site
baseURL string
baseDir string
}
func New(site models.Site, baseURL, baseDir string) *SiteExporter {
return &SiteExporter{
site: site,
baseDir: baseDir,
baseURL: baseURL,
}
}
func (s *SiteExporter) WriteSiteYAML() error {
siteYAML := importexport.Site{
Title: s.site.Title,
Tagline: s.site.Tagline,
BaseURL: s.baseURL,
}
bytes, err := yaml.Marshal(siteYAML)
if err != nil {
return err
}
return os.WriteFile(filepath.Join(s.baseDir, "site.yaml"), bytes, 0644)
}
func (s *SiteExporter) WritePost(post *models.Post) error {
postMeta := importexport.Post{
ID: post.GUID,
Title: post.Title,
Date: post.PublishedAt,
Slug: post.Slug,
}
frontMatter, err := yaml.Marshal(postMeta)
slugBasePath := filepath.Base(post.Slug)
postFilename := filepath.Join(s.baseDir, fmt.Sprintf("posts/%04d/%02d/%02d-%s.md",
post.PublishedAt.Year(), post.PublishedAt.Month(), post.PublishedAt.Day(), slugBasePath))
if err := os.MkdirAll(filepath.Dir(postFilename), 0755); err != nil {
return err
}
f, err := os.Create(postFilename)
if err != nil {
return err
}
defer f.Close()
if _, err := f.WriteString("---\n"); err != nil {
return err
}
if _, err = f.Write(frontMatter); err != nil {
return err
}
_, err = f.WriteString("---\n")
if err != nil {
return err
}
_, err = f.WriteString(post.Body)
return err
}

View file

@ -1,4 +1,4 @@
package importer
package _import
import (
"context"
@ -42,6 +42,9 @@ func (s *Service) Import(ctx context.Context, sitePath string) (models.Site, err
for _, post := range readSite.Posts {
post.SiteID = site.ID
if post.GUID == "" {
post.GUID = models.NewNanoID()
}
if err := s.db.SavePost(ctx, post); err != nil {
return models.Site{}, errors.Wrap(err, "failed to save post")
}

78
services/posts/service.go Normal file
View file

@ -0,0 +1,78 @@
package posts
import (
"context"
"time"
"lmika.dev/lmika/weiro/models"
"lmika.dev/lmika/weiro/providers/db"
"lmika.dev/lmika/weiro/services/publisher"
)
type Service struct {
db *db.Provider
publisher *publisher.Publisher
}
func New(db *db.Provider, publisher *publisher.Publisher) *Service {
return &Service{
db: db,
publisher: publisher,
}
}
type CreatePostParams struct {
GUID string `form:"guid" json:"guid"`
Title string `form:"title" json:"title"`
Body string `form:"body" json:"body"`
}
func (s *Service) PublishPost(ctx context.Context, params CreatePostParams) (*models.Post, error) {
site, ok := models.GetSite(ctx)
if !ok {
return nil, models.SiteRequiredError
}
post, err := s.fetchOrCreatePost(ctx, site, params)
if err != nil {
return nil, err
}
post.Title = params.Title
post.Body = params.Body
post.PublishedAt = time.Now()
post.Slug = post.BestSlug()
if err := s.db.SavePost(ctx, post); err != nil {
return nil, err
}
// TODO: do on separate thread
if err := s.publisher.Publish(ctx, site); err != nil {
return nil, err
}
return post, nil
}
func (s *Service) fetchOrCreatePost(ctx context.Context, site models.Site, params CreatePostParams) (*models.Post, error) {
post, err := s.db.SelectPostByGUID(ctx, params.GUID)
if err == nil {
if post.SiteID != site.ID {
return nil, models.NotFoundError
}
return post, nil
} else if !db.ErrorIsNoRows(err) {
return nil, err
}
post = &models.Post{
SiteID: site.ID,
GUID: params.GUID,
Title: params.Title,
Body: params.Body,
CreatedAt: time.Now(),
}
return post, nil
}

View file

@ -15,6 +15,7 @@ import (
"lmika.dev/lmika/weiro/models/pubmodel"
"lmika.dev/lmika/weiro/providers/db"
"lmika.dev/lmika/weiro/providers/sitebuilder"
"lmika.dev/lmika/weiro/providers/siteexporter"
)
type Publisher struct {
@ -27,27 +28,14 @@ func New(db *db.Provider) *Publisher {
}
}
func (p *Publisher) Publish(ctx context.Context, siteID int64) error {
// Fetch site, ensure user is owner
site, err := p.db.SelectSiteByID(ctx, siteID)
if err != nil {
return err
}
user, ok := models.GetUser(ctx)
if !ok {
return models.UserRequiredError
} else if user.ID != site.OwnerID {
return models.PermissionError
}
targets, err := p.db.SelectPublishTargetsOfSite(ctx, siteID)
func (p *Publisher) Publish(ctx context.Context, site models.Site) error {
targets, err := p.db.SelectPublishTargetsOfSite(ctx, site.ID)
if err != nil {
return err
}
// Fetch all content of site
posts, err := p.db.SelectPostsOfSite(ctx, siteID)
posts, err := p.db.SelectPostsOfSite(ctx, site.ID)
if err != nil {
return err
}
@ -77,6 +65,17 @@ func (p *Publisher) publishSite(ctx context.Context, pubSite pubmodel.Site, targ
}
switch target.TargetType {
case "export":
exporter := siteexporter.New(pubSite.Site, target.BaseURL, target.TargetRef)
if err := exporter.WriteSiteYAML(); err != nil {
return err
}
for _, p := range pubSite.Posts {
if err := exporter.WritePost(p); err != nil {
return err
}
}
return nil
case "localfs":
log.Printf("Building site at %s", target.TargetRef)
return sb.BuildSite(target.TargetRef)

View file

@ -1,6 +1,9 @@
-- name: SelectPostsOfSite :many
SELECT * FROM posts WHERE site_id = ? ORDER BY created_at DESC LIMIT 10;
-- name: SelectPostByGUID :one
SELECT * FROM posts WHERE guid = ? LIMIT 1;
-- name: InsertPost :one
INSERT INTO posts (
site_id,
@ -12,3 +15,11 @@ INSERT INTO posts (
published_at
) VALUES (?, ?, ?, ?, ?, ?, ?)
RETURNING id;
-- name: UpdatePost :exec
UPDATE posts SET
title = ?,
body = ?,
slug = ?,
published_at = ?
WHERE id = ?;

View file

@ -36,3 +36,4 @@ CREATE TABLE posts (
published_at INTEGER NOT NULL
);
CREATE INDEX idx_post_site ON posts (site_id);
CREATE UNIQUE INDEX idx_post_guid ON posts (guid);

20
views/_common/nav.html Normal file
View file

@ -0,0 +1,20 @@
<nav class="navbar navbar-expand-lg bg-body-tertiary">
<div class="container-fluid">
<a class="navbar-brand" href="#">Weiro</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarSupportedContent">
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
<li class="nav-item">
<a class="nav-link active" aria-current="page" href="#">Posts</a>
</li>
</ul>
<form class="d-flex" role="search">
<input class="form-control me-2" type="search" placeholder="Search" aria-label="Search"/>
<button class="btn btn-outline-success" type="submit">Search</button>
</form>
</div>
</div>
</nav>

14
views/layouts/main.html Normal file
View file

@ -0,0 +1,14 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="/static/assets/main.css">
</head>
<body class="min-vh-100 d-flex flex-column">
{{ template "_common/nav" . }}
{{ embed }}
</body>
</html>

1
views/posts/index.html Normal file
View file

@ -0,0 +1 @@
<h1>Posts go here</h1>

14
views/posts/new.html Normal file
View file

@ -0,0 +1,14 @@
<main class="flex-grow-1 position-relative">
<form action="/sites/{{.site.ID}}/posts" method="post" class="container-fluid post-form p-2">
<input type="hidden" name="guid" value="{{ .guid }}">
<div class="mb-2">
<input type="text" name="title" class="form-control" placeholder="Title">
</div>
<div>
<textarea name="body" class="form-control" rows="3"></textarea>
</div>
<div>
<input type="submit" class="btn btn-primary mt-2" value="Publish">
</div>
</form>
</main>