added views

This commit is contained in:
Simon Martens
2024-11-10 00:04:37 +01:00
parent cd108bb5c5
commit dafa217003
131 changed files with 32550 additions and 0 deletions

1
go.mod
View File

@@ -5,6 +5,7 @@ go 1.23.2
require (
github.com/go-git/go-git/v5 v5.12.0
github.com/kelseyhightower/envconfig v1.4.0
github.com/yalue/merged_fs v1.3.0
)
require (

2
go.sum
View File

@@ -71,6 +71,8 @@ github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsT
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM=
github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw=
github.com/yalue/merged_fs v1.3.0 h1:qCeh9tMPNy/i8cwDsQTJ5bLr6IRxbs6meakNE5O+wyY=
github.com/yalue/merged_fs v1.3.0/go.mod h1:WqqchfVYQyclV2tnR7wtRhBddzBvLVR83Cjw9BKQw0M=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=

14
templating/consts.go Normal file
View File

@@ -0,0 +1,14 @@
package templating
var TEMPLATE_FORMATS = []string{".html", ".tmpl", ".gotmpl", ".gotemplate", ".gohtml", ".gohtmltemplate"}
const TEMPLATE_GLOBAL_CONTEXT_NAME = "globals"
const TEMPLATE_ROOT_NAME = "root"
const TEMPLATE_LOCAL_CONTEXT_NAME = "locals"
const TEMPLATE_GLOBAL_PREFIX = "_"
const TEMPLATE_COMPONENT_DIRECTORY = "components"
const TEMPLATE_HEAD = "head"
const TEMPLATE_BODY = "body"
const TEMPLATE_HEADERS = "headers"
const ROOT_LAYOUT_NAME = "root"
const DEFAULT_LAYOUT_NAME = "default"

141
templating/context.go Normal file
View File

@@ -0,0 +1,141 @@
package templating
import (
"html/template"
"io/fs"
"path/filepath"
"slices"
"strings"
)
type TemplateContext struct {
// WARNING: Path is a URL path, NOT a filesystem path
Path string
// WARNING: The keys of these maps are template names, NOT filesystem paths
// The values are FS paths absolute from the root directory of the templates FS
locals map[string]string
globals map[string]string
}
func NewTemplateContext(path string) TemplateContext {
return TemplateContext{
Path: path,
locals: make(map[string]string),
globals: make(map[string]string),
}
}
func (c *TemplateContext) Parse(fsys fs.FS) error {
fspath := PathToFSPath(c.Path)
entries, err := fs.ReadDir(fsys, fspath)
if err != nil {
return NewError(InvalidPathError, c.Path)
}
for _, e := range entries {
if e.IsDir() {
// INFO: components in the components directory can be overwritten
// by components in the base directory down below
// TODO: Maybe allow for subdirectories in the components directory?
if e.Name() == TEMPLATE_COMPONENT_DIRECTORY {
entries, err := fs.ReadDir(fsys, filepath.Join(fspath, e.Name()))
if err != nil {
return NewError(FileAccessError, filepath.Join(fspath, e.Name()))
}
for _, e := range entries {
ext := filepath.Ext(e.Name())
if !slices.Contains(TEMPLATE_FORMATS, ext) {
continue
}
name := strings.TrimSuffix(e.Name(), ext)
if strings.HasPrefix(e.Name(), TEMPLATE_GLOBAL_PREFIX) {
c.globals[name] = filepath.Join(fspath, TEMPLATE_COMPONENT_DIRECTORY, e.Name())
} else {
c.locals[name] = filepath.Join(fspath, TEMPLATE_COMPONENT_DIRECTORY, e.Name())
}
}
continue
}
}
ext := filepath.Ext(e.Name())
if !slices.Contains(TEMPLATE_FORMATS, ext) {
continue
}
name := strings.TrimSuffix(e.Name(), ext)
if strings.HasPrefix(e.Name(), TEMPLATE_GLOBAL_PREFIX) {
c.globals[name] = filepath.Join(fspath, e.Name())
} else {
c.locals[name] = filepath.Join(fspath, e.Name())
}
}
return nil
}
func (c *TemplateContext) SetGlobals(globals map[string]string) error {
// INFO: this allows for overwriting of existing global keys.
// Make sure to call this appopriately before or after Parse(), depending on your use case
for k, v := range globals {
c.globals[k] = v
}
return nil
}
func (c *TemplateContext) Globals() map[string]string {
return c.globals
}
func (c *TemplateContext) Template(fsys fs.FS) (*template.Template, error) {
t, err := readTemplates(fsys, nil, c.globals)
if err != nil {
return nil, err
}
t, err = readTemplates(fsys, t, c.locals)
if err != nil {
return nil, err
}
return t, nil
}
func readTemplates(fsys fs.FS, t *template.Template, paths map[string]string) (*template.Template, error) {
for k, v := range paths {
text, err := fs.ReadFile(fsys, v)
if err != nil {
return nil, NewError(FileAccessError, v)
}
temp, err := template.New(k).Parse(string(text))
if err != nil {
return nil, err
}
if t == nil {
t = temp
continue
}
for _, template := range temp.Templates() {
_, err = t.AddParseTree(template.Name(), template.Tree)
if err != nil {
return nil, err
}
}
_, err = t.AddParseTree(temp.Name(), temp.Tree)
if err != nil {
return nil, err
}
}
return t, nil
}

21
templating/errors.go Normal file
View File

@@ -0,0 +1,21 @@
package templating
import "errors"
var InvalidPathError = errors.New("Invalid path. Must be a directory.")
var NoTemplateError = errors.New("No template found for this name")
var InvalidTemplateError = errors.New("invalid template")
var FileAccessError = errors.New("could not stat file or directory")
type FSError[T error] struct {
File string
Err T
}
func NewError[T error](t T, file string) FSError[T] {
return FSError[T]{File: file, Err: t}
}
func (e FSError[T]) Error() string {
return e.Err.Error() + ": " + e.File
}

32
templating/helpers.go Normal file
View File

@@ -0,0 +1,32 @@
package templating
import "strings"
func PathToFSPath(p string) string {
if p == "/" {
return "."
}
p = strings.TrimPrefix(p, "/")
p = strings.TrimSuffix(p, "/")
return p
}
func FSPathToPath(p string) string {
if p == "." {
return "/"
}
p = strings.TrimPrefix(p, ".")
if !strings.HasPrefix(p, "/") {
p = "/" + p
}
if !strings.HasSuffix(p, "/") {
p = p + "/"
}
return p
}

View File

@@ -0,0 +1,113 @@
package templating
import (
"fmt"
"html/template"
"io/fs"
"sync"
"github.com/yalue/merged_fs"
)
// TODO: Implement Handler interface, maybe in template? But then template would need to know about the layout registry
// Function signature: func (t *templateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
// A static Handler could incoporate both the layout registry and the template registry and serve templates that dont need any data
// INFO: this ist thread-safe and safe to call in a handler or middleware
type LayoutRegistry struct {
layoutsFS fs.FS
once sync.Once
// INFO: Layout & cache keys are template directory names
layouts map[string]TemplateContext
// WARNING: maybe this is too early for caching?
cache sync.Map
funcs template.FuncMap
}
func NewLayoutRegistry(routes fs.FS) *LayoutRegistry {
return &LayoutRegistry{
layoutsFS: routes,
funcs: template.FuncMap{
"safe": func(s string) template.HTML {
return template.HTML(s)
},
},
}
}
// NOTE: Upon registering a new layout dir, we return a new LayoutRegistry
func (r *LayoutRegistry) Register(fs fs.FS) *LayoutRegistry {
return NewLayoutRegistry(merged_fs.MergeMultiple(fs, r.layoutsFS))
}
// TODO: Funcs are not used in executing the templates yet
func (r *LayoutRegistry) RegisterFuncs(funcs template.FuncMap) {
for k, v := range funcs {
r.funcs[k] = v
}
}
func (r *LayoutRegistry) Parse() error {
layouts := make(map[string]TemplateContext)
rootcontext := NewTemplateContext(".")
err := rootcontext.Parse(r.layoutsFS)
if err != nil {
return err
}
globals := rootcontext.Globals()
entries, err := fs.ReadDir(r.layoutsFS, ".")
if err != nil {
return NewError(FileAccessError, ".")
}
for _, e := range entries {
if !e.IsDir() || e.Name() == TEMPLATE_COMPONENT_DIRECTORY {
continue
}
url := FSPathToPath(e.Name())
context := NewTemplateContext(url)
context.SetGlobals(globals)
context.Parse(r.layoutsFS)
layouts[e.Name()] = context
}
r.layouts = layouts
return nil
}
func (r *LayoutRegistry) Layout(name string) (*template.Template, error) {
cached, ok := r.cache.Load(name)
if ok {
return cached.(*template.Template), nil
}
// TODO: What todo on errors?
r.once.Do(func() {
err := r.Parse()
if err != nil {
fmt.Println(err)
panic(-1)
}
})
context, ok := r.layouts[name]
if !ok {
return nil, NewError(NoTemplateError, name)
}
t, err := context.Template(r.layoutsFS)
if err != nil {
return nil, err
}
r.cache.Store(name, t)
return t, nil
}
func (r *LayoutRegistry) Default() (*template.Template, error) {
return r.Layout(DEFAULT_LAYOUT_NAME)
}

View File

@@ -0,0 +1,127 @@
package templating
import (
"fmt"
"html/template"
"io/fs"
"os"
"strings"
"sync"
"github.com/yalue/merged_fs"
)
// INFO: this ist thread-safe and safe to call in a handler or middleware
type TemplateRegistry struct {
routesFS fs.FS
once sync.Once
// INFO: Template & cache keys are directory routing paths, with '/' as root
// INFO: we don't need a mutex here since this is set in Parse() and never changed.
// Parse() is called only once in a thread-safe manner
templates map[string]TemplateContext
cache sync.Map
funcs template.FuncMap
}
func NewTemplateRegistry(routes fs.FS) *TemplateRegistry {
return &TemplateRegistry{
routesFS: routes,
funcs: template.FuncMap{
"safe": func(s string) template.HTML {
return template.HTML(s)
},
},
}
}
// INFO: This returns a new TemplateRegistry with the new fs added to the existing fs,
// merging with the existing FS, possibly overwriting existing files.
func (r *TemplateRegistry) Register(path string, fs fs.FS) *TemplateRegistry {
return NewTemplateRegistry(merged_fs.MergeMultiple(fs, r.routesFS))
}
func (r *TemplateRegistry) RegisterFuncs(funcs template.FuncMap) {
for k, v := range funcs {
r.funcs[k] = v
}
}
// TODO: Throw errors
// TODO: what if there is no template in the directory above?
// What if a certain path is or should uncallable since it has no index or body?
func (r *TemplateRegistry) Parse() error {
// INFO: Parse setrs r.templates, which is why you need to make sure to call Parse() once
templates := make(map[string]TemplateContext)
fs.WalkDir(r.routesFS, ".", func(path string, d fs.DirEntry, err error) error {
if !d.IsDir() {
return nil
}
url := FSPathToPath(path)
tc := NewTemplateContext(url)
if path != "." {
pathelem := strings.Split(path, string(os.PathSeparator))
pathabove := strings.Join(pathelem[:len(pathelem)-1], string(os.PathSeparator))
pathabove = FSPathToPath(pathabove)
global, ok := templates[pathabove]
if ok {
tc.SetGlobals(global.Globals())
}
}
tc.Parse(r.routesFS)
templates[url] = tc
return nil
})
r.templates = templates
return nil
}
// This function takes a template (typically a layout) and adds all the templates of
// a given directory path to it. This is useful for adding a layout to a template.
func (r *TemplateRegistry) Add(path string, t *template.Template) error {
temp, ok := r.cache.Load(path)
if !ok {
// INFO: What todo on errors?
r.once.Do(func() {
err := r.Parse()
if err != nil {
fmt.Println(err)
panic(-1)
}
})
tc, ok := r.templates[path]
if !ok {
return NewError(NoTemplateError, path)
}
template, err := tc.Template(r.routesFS)
if err != nil {
return err
}
r.cache.Store(path, template)
return r.Add(path, t)
}
casted := temp.(*template.Template)
for _, st := range casted.Templates() {
_, err := t.AddParseTree(st.Name(), st.Tree)
if err != nil {
return err
}
}
return nil
}
// TODO: get for a specific component
func (r *TemplateRegistry) Get(path string) error {
return nil
}

8
views/.editorconfig Normal file
View File

@@ -0,0 +1,8 @@
[*]
trim_trailing_whitespace = true
charset = utf-8
insert_final_newline = true
end_of_line = lf
indent_style = tab
indent_size = 2
max_line_length = 100

24
views/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

10
views/.prettierrc Normal file
View File

@@ -0,0 +1,10 @@
{
bracketSameLine: true,
bracketSpacing: true,
whitespaceSensitivity: "ignore",
proseWrap: "always",
bracketLine: true,
useTabs: true,
tabWidth: 2,
"plugins": ["prettier-plugin-go-template"]
}

2952
views/assets/Diagram.svg Normal file

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 117 KiB

File diff suppressed because it is too large Load Diff

BIN
views/assets/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

5
views/assets/js/alpine.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,130 @@
(function(){
/** @type {import("../htmx").HtmxInternalApi} */
var api;
var attrPrefix = 'hx-target-';
// IE11 doesn't support string.startsWith
function startsWith(str, prefix) {
return str.substring(0, prefix.length) === prefix
}
/**
* @param {HTMLElement} elt
* @param {number} respCode
* @returns {HTMLElement | null}
*/
function getRespCodeTarget(elt, respCodeNumber) {
if (!elt || !respCodeNumber) return null;
var respCode = respCodeNumber.toString();
// '*' is the original syntax, as the obvious character for a wildcard.
// The 'x' alternative was added for maximum compatibility with HTML
// templating engines, due to ambiguity around which characters are
// supported in HTML attributes.
//
// Start with the most specific possible attribute and generalize from
// there.
var attrPossibilities = [
respCode,
respCode.substr(0, 2) + '*',
respCode.substr(0, 2) + 'x',
respCode.substr(0, 1) + '*',
respCode.substr(0, 1) + 'x',
respCode.substr(0, 1) + '**',
respCode.substr(0, 1) + 'xx',
'*',
'x',
'***',
'xxx',
];
if (startsWith(respCode, '4') || startsWith(respCode, '5')) {
attrPossibilities.push('error');
}
for (var i = 0; i < attrPossibilities.length; i++) {
var attr = attrPrefix + attrPossibilities[i];
var attrValue = api.getClosestAttributeValue(elt, attr);
if (attrValue) {
if (attrValue === "this") {
return api.findThisElement(elt, attr);
} else {
return api.querySelectorExt(elt, attrValue);
}
}
}
return null;
}
/** @param {Event} evt */
function handleErrorFlag(evt) {
if (evt.detail.isError) {
if (htmx.config.responseTargetUnsetsError) {
evt.detail.isError = false;
}
} else if (htmx.config.responseTargetSetsError) {
evt.detail.isError = true;
}
}
htmx.defineExtension('response-targets', {
/** @param {import("../htmx").HtmxInternalApi} apiRef */
init: function (apiRef) {
api = apiRef;
if (htmx.config.responseTargetUnsetsError === undefined) {
htmx.config.responseTargetUnsetsError = true;
}
if (htmx.config.responseTargetSetsError === undefined) {
htmx.config.responseTargetSetsError = false;
}
if (htmx.config.responseTargetPrefersExisting === undefined) {
htmx.config.responseTargetPrefersExisting = false;
}
if (htmx.config.responseTargetPrefersRetargetHeader === undefined) {
htmx.config.responseTargetPrefersRetargetHeader = true;
}
},
/**
* @param {string} name
* @param {Event} evt
*/
onEvent: function (name, evt) {
if (name === "htmx:beforeSwap" &&
evt.detail.xhr &&
evt.detail.xhr.status !== 200) {
if (evt.detail.target) {
if (htmx.config.responseTargetPrefersExisting) {
evt.detail.shouldSwap = true;
handleErrorFlag(evt);
return true;
}
if (htmx.config.responseTargetPrefersRetargetHeader &&
evt.detail.xhr.getAllResponseHeaders().match(/HX-Retarget:/i)) {
evt.detail.shouldSwap = true;
handleErrorFlag(evt);
return true;
}
}
if (!evt.detail.requestConfig) {
return true;
}
var target = getRespCodeTarget(evt.detail.requestConfig.elt, evt.detail.xhr.status);
if (target) {
handleErrorFlag(evt);
evt.detail.shouldSwap = true;
evt.detail.target = target;
}
return true;
}
}
});
})();

1
views/assets/js/htmx.min.js vendored Normal file

File diff suppressed because one or more lines are too long

22
views/assets/scripts.js Normal file
View File

@@ -0,0 +1,22 @@
function a() {
document.querySelectorAll("template[simple]").forEach((l) => {
let s = l.getAttribute("id"), n = l.content;
customElements.define(s, class extends HTMLElement {
constructor() {
super(), this.appendChild(n.cloneNode(!0)), this.slots = this.querySelectorAll("slot");
}
connectedCallback() {
let o = [];
this.slots.forEach((e) => {
let r = e.getAttribute("name"), t = this.querySelector(`[slot="${r}"]`);
t && (e.replaceWith(t.cloneNode(!0)), o.push(t));
}), o.forEach((e) => {
e.remove();
});
}
});
});
}
export {
a as setup
};

1
views/assets/style.css Normal file

File diff suppressed because one or more lines are too long

33
views/embed.go Normal file
View File

@@ -0,0 +1,33 @@
//go:build !dev
// +build !dev
// Package ui handles the PocketBase Admin frontend embedding.
// we could use io/fs.Sub to get a sub filesystem, but it errors. echo.MustSubFS throws on error
package views
import (
"embed"
"io/fs"
)
//go:embed all:assets
var ui_static embed.FS
var StaticFS = MustSubFS(ui_static, "assets")
//go:embed all:routes
var ui_routes embed.FS
var RoutesFS = MustSubFS(ui_routes, "routes")
//go:embed all:layouts
var ui_layouts embed.FS
var LayoutFS = MustSubFS(ui_layouts, "layouts")
func MustSubFS(fsys fs.FS, dir string) fs.FS {
sub, err := fs.Sub(fsys, dir)
if err != nil {
panic("Could not create SubFS for " + dir)
}
return sub
}

17
views/embed_dev.go Normal file
View File

@@ -0,0 +1,17 @@
//go:build dev
package views
import (
"os"
)
const (
STATIC_FILEPATH = "./views/assets"
ROUTES_FILEPATH = "./views/routes"
LAYOUT_FILEPATH = "./views/layouts"
)
var StaticFS = os.DirFS(STATIC_FILEPATH)
var RoutesFS = os.DirFS(ROUTES_FILEPATH)
var LayoutFS = os.DirFS(LAYOUT_FILEPATH)

View File

@@ -0,0 +1,27 @@
<!doctype html>
<html class="w-full h-full" lang="de">
<head>
{{ block "head" . }}
<!-- Default Head elements -->
{{ end }}
<link rel="stylesheet" type="text/css" href="/assets/style.css" />
<link href="/assets/css/remixicon.css" rel="stylesheet" />
<script src="/assets/js/alpine.min.js" defer></script>
<script src="/assets/js/htmx.min.js" defer></script>
<script src="/assets/js/htmx-response-targets.js" defer></script>
<script type="module">
import { setup } from "/assets/scripts.js";
setup();
</script>
</head>
<body class="w-full h-full" hx-ext="response-targets">
{{ block "body" . }}
<!-- Default app body... -->
{{ end }}
</body>
</html>

2479
views/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

30
views/package.json Normal file
View File

@@ -0,0 +1,30 @@
{
"name": "caveman_views",
"version": "1.0.0",
"description": "default views for caveman",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"repository": {
"type": "git",
"url": "github.com/Simon-Martens/pocketcatalog"
},
"keywords": [
"DB",
"htmx",
"frontend"
],
"author": "Simon Martens",
"license": "MIT",
"devDependencies": {
"autoprefixer": "^10.4.20",
"postcss": "^8.4.47",
"prettier": "^3.3.3",
"prettier-plugin-go-template": "^0.0.15",
"tailwindcss": "^3.4.13",
"vite": "^5.4.8"
}
}

8
views/postcss.config.js Normal file
View File

@@ -0,0 +1,8 @@
export default {
plugins: {
'postcss-import': {},
'tailwindcss/nesting': {},
tailwindcss: {},
autoprefixer: {},
},
}

2952
views/public/Diagram.svg Normal file

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 117 KiB

File diff suppressed because it is too large Load Diff

BIN
views/public/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Some files were not shown because too many files have changed in this diff Show More