chore: initial commit, move from loreddev/x

Moved the source code to a new repository outside from loreddev/x. To
see the commit history before this commit, see
1f823aa099
This commit is contained in:
Guz
2025-01-29 11:24:10 -03:00
commit 8f3245684b
33 changed files with 4101 additions and 0 deletions

2
.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
.direnv
.envrc

202
LICENSE Normal file
View File

@@ -0,0 +1,202 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

19
README.md Normal file
View File

@@ -0,0 +1,19 @@
# Blo.go
> This package is still in heavy development and it is being dog fed
> in [capytal/www](https://forge.capytal.company/capytal/www).
## License
© 2025-present Gustavo "Guz" L de Mello
© 2025-present The Lored.dev Contributors
Licensed under the Apache License, Version 2.0 (the "License"), unless otherwise
explicitly noted. You may obtain a copy of the License at [LICENSE](./LICENSE) or
[http://www.apache.org/licenses/LICENSE-2.0](http://www.apache.org/licenses/LICENSE-2.0).
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

423
blogo.go Normal file
View File

@@ -0,0 +1,423 @@
// Copyright 2025-present Gustavo "Guz" L. de Mello
// Copyright 2025-present The Lored.dev Contributors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package blogo
import (
"html/template"
"io"
"log/slog"
"net/http"
"forge.capytal.company/loreddev/blogo/core"
"forge.capytal.company/loreddev/blogo/plugin"
"forge.capytal.company/loreddev/blogo/plugins"
"forge.capytal.company/loreddev/x/tinyssert"
)
var defaultNotFoundTemplate = template.Must(
template.New("not-found").Parse("404: Blog post {{.Path}} not found"),
)
var defaultInternalErrTemplate = template.Must(
template.New("internal-err").
Parse("500: Failed to get blog post {{.Path}} due to error {{.ErrorMsg}}\n{{.Error}}"),
)
// The main function of the package. Creates a new [Blogo] implementation.
//
// This implementation automatically adds fallbacks and uses built-in [plugins] to handle
// multiple sources, renderers and error handlers.
//
// Use [Opts] to more fine grained control of what plugins are used on initialization. To have
// complete control over how plugins are managed, use the [core] package and [plugins]
// for more information and building blocks to create a custom [Blogo] implementation.
func New(opts ...Opts) Blogo {
opt := Opts{}
if len(opts) > 0 {
opt = opts[0]
}
if opt.Assertions == nil {
opt.Assertions = tinyssert.NewDisabledAssertions()
}
if opt.Logger == nil {
opt.Logger = slog.New(slog.NewTextHandler(io.Discard, &slog.HandlerOptions{}))
}
if opt.FallbackRenderer == nil {
opt.FallbackRenderer = plugins.NewPlainText(plugins.PlainTextOpts{
Assertions: opt.Assertions,
})
}
if opt.MultiRenderer == nil {
opt.MultiRenderer = plugins.NewBufferedMultiRenderer(plugins.BufferedMultiRendererOpts{
Assertions: opt.Assertions,
Logger: opt.Logger.WithGroup("multi-renderer"),
})
}
if opt.FallbackSourcer == nil {
opt.FallbackSourcer = plugins.NewEmptySourcer()
}
if opt.MultiSourcer == nil {
opt.MultiSourcer = plugins.NewMultiSourcer(plugins.MultiSourcerOpts{
SkipOnSourceError: true,
SkipOnFSError: true,
Assertions: opt.Assertions,
Logger: opt.Logger.WithGroup("multi-sourcer"),
})
}
if opt.FallbackErrorHandler == nil {
logger := opt.Logger.WithGroup("errors")
f := plugins.NewMultiErrorHandler(plugins.MultiErrorHandlerOpts{
Assertions: opt.Assertions,
Logger: logger,
})
f.Use(plugins.NewNotFoundErrorHandler(
*defaultNotFoundTemplate,
plugins.TemplateErrorHandlerOpts{
Assertions: opt.Assertions,
Logger: logger.WithGroup("not-found"),
},
))
f.Use(plugins.NewTemplateErrorHandler(
*defaultInternalErrTemplate,
plugins.TemplateErrorHandlerOpts{
Assertions: opt.Assertions,
Logger: logger.WithGroup("internal-err"),
},
))
f.Use(plugins.NewLoggerErrorHandler(logger.WithGroup("logger"), slog.LevelError))
opt.FallbackErrorHandler = f
}
if opt.MultiErrorHandler == nil {
opt.MultiErrorHandler = plugins.NewMultiErrorHandler(plugins.MultiErrorHandlerOpts{
Assertions: opt.Assertions,
Logger: opt.Logger.WithGroup("errors"),
})
}
return &blogo{
plugins: []plugin.Plugin{},
fallbackRenderer: opt.FallbackRenderer,
multiRenderer: opt.MultiRenderer,
fallbackSourcer: opt.FallbackSourcer,
multiSourcer: opt.MultiSourcer,
fallbackErrorHandler: opt.FallbackErrorHandler,
multiErrorHandler: opt.MultiErrorHandler,
assert: opt.Assertions,
log: opt.Logger,
}
}
// The simplest interface of the [blogo] package's blogging engine.
//
// Users should use the [New] function to easily have a implementation
// that handles plugins and initialization out-of-the-box, or implement
// their own to have more fine grained control over the package.
type Blogo interface {
// Adds a new plugin to the engine.
//
// Implementations may accept any type of plugin interface. The default
// implementation accepts [plugin.Sourcer], [plugin.Renderer], [plugin.ErrorHandler],
// and [plugin.Group], ignoring any other plugins or nil values silently.
Use(plugin.Plugin)
// Initialize the plugins or internal state if necessary.
//
// Implementations may call it on the fist request and/or panic on failed initialization.
// The default implementation calls Init on the first call to ServeHTTP
Init()
// The main entry point to access all blog posts.
//
// Implementations may not expect to the ServeHTTP's request to have a path different from
// "/". Users should use http.StripPrefix if they are using it on a defined path, for example:
//
// http.Handle("/blog", http.StripPrefix("/blog/", blogo))
//
// Implementations of this interface may add other method to access the blog posts
// besides just http requests. Plugins that register API endpoints will handle them
// inside this handler, in other words, endpoints paths will be appended to any path
// that this Handler is being used in.
http.Handler
}
// Options used by [New] to better fine grain the default plugins used by the
// default [Blogo] implementation.
type Opts struct {
// The plugin that will be used if no [plugin.Renderer] is provided.
// Defaults to [plugins.NewPlainText].
FallbackRenderer plugin.Renderer
// What plugin will be used to combine multiple renderers if necessary.
MultiRenderer interface {
plugin.Renderer
plugin.WithPlugins
}
// The plugin that will be used if no [plugin.Sourcer] is provided.
// Defaults to [plugins.NewEmptySourcer].
FallbackSourcer plugin.Sourcer
// What plugin will be used to combine multiple sourcers if necessary.
MultiSourcer interface {
plugin.Sourcer
plugin.WithPlugins
}
// The plugin that will be used if no [plugin.ErrorHandler] is provided.
// Defaults to a MultiErrorHandler with [plugins.NewNotFoundErrorHandler],
// [plugins.NewTemplateErrorHandler] and [plugins.NewLoggerErrorHandler].
FallbackErrorHandler plugin.ErrorHandler
// What plugin will be used to combine multiple error handlers.
MultiErrorHandler interface {
plugin.ErrorHandler
plugin.WithPlugins
}
// [tinyssert.Assertions] implementation used Assertions, by default
// uses [tinyssert.NewDisabledAssertions] to effectively disable assertions.
// Use this if to fail-fast on incorrect states. This is also passed to the
// default built-in plugins on initialization.
Assertions tinyssert.Assertions
// Logger to be used to send error, warns and debug messages, useful for plugin
// development and debugging the pipeline of files. By default it uses a logger
// that writes to [io.Discard], effectively disabling logging. This is passed
// to the default built-in plugins on initialization.
Logger *slog.Logger
}
type blogo struct {
plugins []plugin.Plugin
fallbackRenderer plugin.Renderer
multiRenderer interface {
plugin.Renderer
plugin.WithPlugins
}
fallbackSourcer plugin.Sourcer
multiSourcer interface {
plugin.Sourcer
plugin.WithPlugins
}
fallbackErrorHandler plugin.ErrorHandler
multiErrorHandler interface {
plugin.ErrorHandler
plugin.WithPlugins
}
server http.Handler
assert tinyssert.Assertions
log *slog.Logger
}
func (b *blogo) Use(p plugin.Plugin) {
b.assert.NotNil(p, "Plugin definition should not be nil")
b.assert.NotNil(b.plugins, "Plugins needs to be not-nil")
b.assert.NotNil(b.log)
log := b.log.With(slog.String("plugin", p.Name()))
if p, ok := p.(plugin.Group); ok {
log.Debug("Plugin group found, adding it's plugins")
for _, p := range p.Plugins() {
b.Use(p)
}
}
if p != nil {
b.plugins = append(b.plugins, p)
}
}
func (b *blogo) ServeHTTP(w http.ResponseWriter, r *http.Request) {
b.assert.NotNil(b.log)
b.assert.NotNil(w)
b.assert.NotNil(r)
if b.server != nil {
b.server.ServeHTTP(w, r)
return
}
log := b.log.With()
log.Debug("Core server not initialized")
b.Init()
b.server.ServeHTTP(w, r)
}
func (b *blogo) Init() {
b.assert.NotNil(b.plugins, "Plugins needs to be not-nil")
b.assert.NotNil(b.log)
log := b.log.With()
log.Debug("Initializing Blogo plugins")
sourcer := b.initSourcer()
renderer := b.initRenderer()
errorHandler := b.initErrorHandler()
log.Debug("Constructing Blogo server")
b.server = core.NewServer(sourcer, renderer, errorHandler, core.ServerOpts{
Assertions: b.assert,
Logger: b.log.WithGroup("server"),
})
log.Debug("Server constructed")
}
func (b *blogo) initRenderer() plugin.Renderer {
b.assert.NotNil(b.plugins, "Plugins needs to be not-nil")
b.assert.NotNil(b.fallbackRenderer, "FallbackRenderer needs to be not-nil")
b.assert.NotNil(b.multiRenderer, "MultiRenderer needs to be not-nil")
b.assert.NotNil(b.log)
log := b.log.With()
log.Debug("Initializing Blogo Renderer plugins")
renderers := []plugin.Renderer{}
for _, p := range b.plugins {
if r, ok := p.(plugin.Renderer); ok {
log.Debug("Adding Renderer", slog.String("sourcer", r.Name()))
renderers = append(renderers, r)
}
}
if len(renderers) == 0 {
log.Debug("No Renderer avaiable, using %q as fallback",
slog.String("renderer", b.fallbackRenderer.Name()))
return b.fallbackRenderer
}
if len(renderers) == 1 {
log.Debug("Just one Renderer found, using it directly",
slog.String("renderer", renderers[0].Name()))
return renderers[0]
}
log.Debug("Multiple Renderers found, using MultiRenderer to combine them",
slog.String("renderer", b.multiRenderer.Name()),
)
for _, r := range renderers {
b.multiRenderer.Use(r)
}
return b.multiRenderer
}
func (b *blogo) initSourcer() plugin.Sourcer {
b.assert.NotNil(b.plugins, "Plugins needs to be not-nil")
b.assert.NotNil(b.fallbackSourcer, "FallbackSourcer needs to be not-nil")
b.assert.NotNil(b.multiSourcer, "MultiSourcer needs to be not-nil")
b.assert.NotNil(b.log)
log := b.log.With()
log.Debug("Initializing Blogo Sourcer plugins")
sourcers := []plugin.Sourcer{}
for _, p := range b.plugins {
if s, ok := p.(plugin.Sourcer); ok {
log.Debug("Adding Sourcer", slog.String("sourcer", s.Name()))
sourcers = append(sourcers, s)
}
}
if len(sourcers) == 0 {
log.Debug("No Sourcer avaiable, using %q as fallback",
slog.String("sourcer", b.fallbackSourcer.Name()))
return b.fallbackSourcer
}
if len(sourcers) == 1 {
log.Debug("Just one Sourcer found, using it directly",
slog.String("sourcer", sourcers[0].Name()))
return sourcers[0]
}
log.Debug("Multiple Sourcers found, using MultiSourcer to combine them",
slog.String("sourcer", b.multiSourcer.Name()),
)
for _, s := range sourcers {
b.multiSourcer.Use(s)
}
return b.multiSourcer
}
func (b *blogo) initErrorHandler() plugin.ErrorHandler {
b.assert.NotNil(b.plugins, "Plugins needs to be not-nil")
b.assert.NotNil(b.fallbackErrorHandler, "FallbackErrorHandler needs to be not-nil")
b.assert.NotNil(b.multiErrorHandler, "MultiErrorHandler needs to be not-nil")
b.assert.NotNil(b.log)
log := b.log.With()
log.Debug("Initializing Blogo ErrorHandler plugins")
errorHandlers := []plugin.ErrorHandler{}
for _, p := range b.plugins {
if s, ok := p.(plugin.ErrorHandler); ok {
log.Debug("Adding ErrorHandler", slog.String("errorHandler", s.Name()))
errorHandlers = append(errorHandlers, s)
}
}
if len(errorHandlers) == 0 {
log.Debug("No ErrorHandler avaiable, using %q as fallback",
slog.String("errorHandler", b.fallbackErrorHandler.Name()))
return b.fallbackErrorHandler
}
if len(errorHandlers) == 1 {
log.Debug("Just one ErrorHandler found, using it directly",
slog.String("errorHandler", errorHandlers[0].Name()))
return errorHandlers[0]
}
log.Debug("Multiple ErrorHandlers found, using MultiSourcer to combine them",
slog.String("errorHandler", b.multiErrorHandler.Name()),
)
for _, s := range errorHandlers {
b.multiErrorHandler.Use(s)
}
return b.multiErrorHandler
}

332
core/core.go Normal file
View File

@@ -0,0 +1,332 @@
// Copyright 2025-present Gustavo "Guz" L. de Mello
// Copyright 2025-present The Lored.dev Contributors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package core
import (
"fmt"
"io"
"io/fs"
"log/slog"
"net/http"
"strings"
"forge.capytal.company/loreddev/blogo/plugin"
"forge.capytal.company/loreddev/x/tinyssert"
)
// Creates a implementation of [http.Handler] that maps the [(*http.Request).Path] to a file of the
// same name in the file system provided by the sourcer. Use [Opts] to have more fine grained control
// over some additional behaviour of the implementation.
func NewServer(
sourcer plugin.Sourcer,
renderer plugin.Renderer,
onerror plugin.ErrorHandler,
opts ...ServerOpts,
) http.Handler {
opt := ServerOpts{}
if len(opts) > 0 {
opt = opts[0]
}
if opt.Assertions == nil {
opt.Assertions = tinyssert.NewDisabledAssertions()
}
if opt.Logger == nil {
opt.Logger = slog.New(slog.NewTextHandler(io.Discard, &slog.HandlerOptions{}))
}
var filesystem fs.FS
if opt.SourceOnInit {
fs, err := sourcer.Source()
if err != nil {
panic(fmt.Sprintf("Failed to source files on initialization due to error: %s",
err.Error(),
))
}
filesystem = fs
}
return &server{
files: filesystem,
sourcer: sourcer,
renderer: renderer,
onerror: onerror,
assert: opt.Assertions,
log: opt.Logger,
}
}
// Options used in the construction of the server/[http.Handler] in [NewServer] to better
// control additional behaviour of the implementation.
type ServerOpts struct {
// Call [(plugin.Sourcer).Source] on construction of the implementation on [NewServer]?
// Panics if the it returns a error. By default sourcing of files is done on the first
// request.
SourceOnInit bool
// [tinyssert.Assertions] implementation used by server for it's Assertions, by default
// uses [tinyssert.NewDisabledAssertions] to effectively disable assertions. Use this
// if you want to the server to fail-fast on incorrect states.
Assertions tinyssert.Assertions
// Logger to be used to send error, warns and debug messages, useful for plugin development
// and debugging the pipeline of files. By default it uses a logger that writes to [io.Discard],
// effectively disabling logging.
Logger *slog.Logger
}
type server struct {
files fs.FS
sourcer plugin.Sourcer
renderer plugin.Renderer
onerror plugin.ErrorHandler
assert tinyssert.Assertions
log *slog.Logger
}
func (srv *server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
srv.assert.NotNil(srv.log)
srv.assert.NotNil(w)
srv.assert.NotNil(r)
log := srv.log.With(slog.String("path", r.URL.Path))
log.Debug("Serving endpoint")
if srv.files == nil {
err := srv.serveHTTPSource(w, r)
if err != nil {
return
}
}
path := strings.Trim(r.URL.Path, "/")
if path == "" || path == "/" {
path = "."
}
file, err := srv.serveHTTPOpenFile(path, w, r)
if err != nil {
return
}
// Defers the closing of the file to prevent memory being held if a renderer
// does not properly closes the file.
defer file.Close()
err = srv.serveHTTPRender(file, w, r)
if err != nil {
return
}
log.Debug("Finished serving endpoint")
}
func (srv *server) serveHTTPSource(w http.ResponseWriter, r *http.Request) error {
srv.assert.NotNil(srv.sourcer, "A sourcer needs to be available")
srv.assert.NotNil(srv.onerror, "An error handler needs to be available in cases of errors")
srv.assert.NotNil(srv.log)
srv.assert.NotNil(w)
srv.assert.NotNil(r)
log := srv.log.With(slog.String("path", r.URL.Path), slog.String("sourcer", srv.sourcer.Name()))
log.Debug("Initializing file system")
fs, err := srv.sourcer.Source()
if err != nil {
log := log.With(
slog.String("err", err.Error()),
slog.String("errorhandler", srv.onerror.Name()),
)
log.Error(
"Failed to get file system, handling error to ErrorHandler",
)
recovr, ok := srv.onerror.Handle(&ServeError{
Res: w,
Req: r,
Err: &SourceError{
Sourcer: srv.sourcer,
Err: err,
},
})
if !ok {
log.Error("Failed to handle error with plugin")
w.WriteHeader(http.StatusInternalServerError)
_, err = w.Write([]byte(fmt.Sprintf(
"Failed to handle error %q with plugin %q",
err.Error(),
srv.onerror.Name(),
)))
srv.assert.Nil(err)
return err
}
r, ok := recovr.(plugin.Sourcer)
if !ok {
return err
}
fs, err = r.Source()
srv.assert.Nil(err)
}
srv.files = fs
return nil
}
func (srv *server) serveHTTPOpenFile(
name string,
w http.ResponseWriter,
r *http.Request,
) (fs.File, error) {
srv.assert.NotZero(name, "Name of file should not be empty")
srv.assert.NotNil(srv.files, "A file system needs to be present to open a file")
srv.assert.NotNil(srv.onerror, "An error handler needs to be available in cases of errors")
srv.assert.NotNil(srv.log)
srv.assert.NotNil(w)
srv.assert.NotNil(r)
log := srv.log.With(
slog.String("path", r.URL.Path),
slog.String("filename", name),
slog.String("sourcer", srv.sourcer.Name()),
)
log.Debug("Opening file")
f, err := srv.files.Open(name)
if err != nil || f == nil {
if err == nil && f == nil {
err = fmt.Errorf(
"file system returned a nil file using sourcer %q",
srv.sourcer.Name(),
)
}
log := log.With(
slog.String("err", err.Error()),
slog.String("errorhandler", srv.onerror.Name()),
)
log.Warn(
"Failed to open file, handling error to ErrorHandler",
)
recovr, ok := srv.onerror.Handle(ServeError{
Res: w,
Req: r,
Err: SourceError{
Sourcer: srv.sourcer,
Err: err,
},
})
if !ok {
log.Error("Failed to handle error with plugin")
w.WriteHeader(http.StatusInternalServerError)
_, err = w.Write([]byte(fmt.Sprintf(
"Failed to handle error %q with plugin %q",
err.Error(),
srv.onerror.Name(),
)))
srv.assert.Nil(err)
return nil, err
}
r, ok := recovr.(fs.FS)
if !ok {
return nil, err
}
f, err = r.Open(name)
srv.assert.Nil(err)
}
return f, err
}
func (srv *server) serveHTTPRender(file fs.File, w http.ResponseWriter, r *http.Request) error {
srv.assert.NotNil(file, "A file needs to be present to it to be rendered")
srv.assert.NotNil(srv.renderer, "A renderer needs to be present to render a file")
srv.assert.NotNil(srv.onerror, "An error handler needs to be available in cases of errors")
srv.assert.NotNil(srv.log)
srv.assert.NotNil(w)
srv.assert.NotNil(r)
log := srv.log.With(
slog.String("path", r.URL.Path),
slog.String("renderer", srv.renderer.Name()),
)
log.Debug("Rendering file")
err := srv.renderer.Render(file, w)
if err != nil {
log := log.With(
slog.String("err", err.Error()),
slog.String("errorhandler", srv.onerror.Name()),
)
log.Error(
"Failed to render file, handling error to ErrorHandler",
)
recovr, ok := srv.onerror.Handle(ServeError{
Res: w,
Req: r,
Err: RenderError{
Renderer: srv.renderer,
File: file,
Err: err,
},
})
if !ok {
log.Error("Failed to handle error with plugin")
w.WriteHeader(http.StatusInternalServerError)
_, err = w.Write([]byte(fmt.Sprintf(
"Failed to handle error %q with plugin %q",
err.Error(),
srv.onerror.Name(),
)))
srv.assert.Nil(err)
return err
}
r, ok := recovr.(plugin.Renderer)
if !ok {
return err
}
err = r.Render(file, w)
srv.assert.Nil(err)
}
return nil
}

65
core/errors.go Normal file
View File

@@ -0,0 +1,65 @@
// Copyright 2025-present Gustavo "Guz" L. de Mello
// Copyright 2025-present The Lored.dev Contributors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package core
import (
"fmt"
"io/fs"
"net/http"
"forge.capytal.company/loreddev/blogo/plugin"
)
type ServeError struct {
Res http.ResponseWriter
Req *http.Request
Err error
}
func (e ServeError) Error() string {
return fmt.Sprintf("failed to serve file on path %q", e.Req.URL.Path)
}
func (e ServeError) Unwrap() error {
return e.Err
}
type SourceError struct {
Sourcer plugin.Sourcer
Err error
}
func (e SourceError) Error() string {
return fmt.Sprintf("failed to source files with sourcer %q", e.Sourcer.Name())
}
func (e SourceError) Unwrap() error {
return e.Err
}
type RenderError struct {
Renderer plugin.Renderer
File fs.File
Err error
}
func (e RenderError) Error() string {
return fmt.Sprintf("failed to source files with renderer %q", e.Renderer.Name())
}
func (e RenderError) Unwrap() error {
return e.Err
}

27
flake.lock generated Normal file
View File

@@ -0,0 +1,27 @@
{
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1734424634,
"narHash": "sha256-cHar1vqHOOyC7f1+tVycPoWTfKIaqkoe1Q6TnKzuti4=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "d3c42f187194c26d9f0309a8ecc469d6c878ce33",
"type": "github"
},
"original": {
"owner": "nixos",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"nixpkgs": "nixpkgs"
}
}
},
"root": "root",
"version": 7
}

36
flake.nix Normal file
View File

@@ -0,0 +1,36 @@
{
description = "My development environment";
inputs = {
nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
};
outputs = {nixpkgs, ...}: let
systems = [
"x86_64-linux"
"aarch64-linux"
"x86_64-darwin"
"aarch64-darwin"
];
forAllSystems = f:
nixpkgs.lib.genAttrs systems (system: let
pkgs = import nixpkgs {inherit system;};
in
f system pkgs);
in {
devShells = forAllSystems (system: pkgs: {
default = pkgs.mkShell {
CGO_ENABLED = "0";
hardeningDisable = ["all"];
buildInputs = with pkgs; [
# Go tools
go
gofumpt
golangci-lint
golines
gotools
delve
];
};
});
};
}

11
go.mod Normal file
View File

@@ -0,0 +1,11 @@
module forge.capytal.company/loreddev/blogo
go 1.23.4
require (
forge.capytal.company/loreddev/x v0.0.0-20250128201807-1f823aa0998d
github.com/yuin/goldmark v1.7.8
github.com/yuin/goldmark-meta v1.1.0
)
require gopkg.in/yaml.v2 v2.3.0 // indirect

10
go.sum Normal file
View File

@@ -0,0 +1,10 @@
forge.capytal.company/loreddev/x v0.0.0-20250128201807-1f823aa0998d h1:TtbawjKOZq872Xr4nIgI3FNsJiJhYLZ0sYzKLKl+7yA=
forge.capytal.company/loreddev/x v0.0.0-20250128201807-1f823aa0998d/go.mod h1:MnU08vmXvYIQlQutVcC6o6Xq1KHZuXGXO78bbHseCFo=
github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic=
github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
github.com/yuin/goldmark-meta v1.1.0 h1:pWw+JLHGZe8Rk0EGsMVssiNb/AaPMHfSRszZeUeiOUc=
github.com/yuin/goldmark-meta v1.1.0/go.mod h1:U4spWENafuA7Zyg+Lj5RqK/MF+ovMYtBvXi1lBb2VP0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.3.0 h1:clyUAQHOM3G0M3f5vQj7LuJrETvjVot3Z5el9nffUtU=
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=

320
metadata/metadata.go Normal file
View File

@@ -0,0 +1,320 @@
// Copyright 2025-present Gustavo "Guz" L. de Mello
// Copyright 2025-present The Lored.dev Contributors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// [Metadata] is a simple key-value data structure that should be used by
// plugins to pass data between their processes and between plugins.
//
// This package provides a collection of types, interfaces and functions to
// help create and manipulate said data structure.
package metadata
import (
"errors"
"reflect"
)
var (
ErrImmutable = errors.New("metadata is immutable")
ErrInvalidType = errors.New("key is not of specified type")
ErrNotFound = errors.New("value not found metadata")
ErrNoMetadata = errors.New("type does not implement or has metadata")
)
// Gets a value from a [Metadata] or [WithMetadata] objects and tries
// to convert it to the specified type. If m implements [TypedMetadata],
// tries to use the typed methods directly.
//
// For more information, see [Get].
//
// If the value is not of the specified type, returns [ErrInvalidType].
func GetTyped[T any](m any, key string) (T, error) {
var z T
if m, ok := m.(TypedMetadata); ok {
v, err := getTypedFromTyped[T](m, key)
if v, ok := v.(T); ok && err == nil {
return v, err
}
}
v, err := Get(m, key)
if err != nil {
return z, err
}
if v, ok := v.(T); ok {
return v, nil
}
vv, zv := reflect.ValueOf(v), reflect.ValueOf(z)
vt, zt := vv.Type(), zv.Type()
if vt.ConvertibleTo(zt) {
v = vv.Convert(zt).Interface()
if v, ok := v.(T); ok {
return v, nil
}
}
return z, ErrInvalidType
}
func getTypedFromTyped[T any](m TypedMetadata, key string) (any, error) {
var z T
t := reflect.TypeOf(z)
if t == nil {
return z, ErrInvalidType
}
switch t.Kind() {
case reflect.Bool:
return m.GetBool(key)
case reflect.String:
return m.GetString(key)
case reflect.Int:
return m.GetInt(key)
case reflect.Int8:
return m.GetInt8(key)
case reflect.Int16:
return m.GetInt16(key)
case reflect.Int32:
return m.GetInt32(key)
case reflect.Int64:
return m.GetInt64(key)
case reflect.Uint:
return m.GetInt(key)
case reflect.Uint8:
return m.GetUint8(key)
case reflect.Uint16:
return m.GetUint16(key)
case reflect.Uint32:
return m.GetUint32(key)
case reflect.Uint64:
return m.GetUint64(key)
case reflect.Uintptr:
return m.GetUintptr(key)
case reflect.Float32:
return m.GetFloat32(key)
case reflect.Float64:
return m.GetFloat64(key)
case reflect.Complex64:
return m.GetComplex64(key)
case reflect.Complex128:
return m.GetComplex128(key)
default:
return m.Get(key)
}
}
// Gets a value from m, if it implements [Metadata] or [WithMetadata], otherwise returns
// [ErrNoMetadata].
//
// If there is metadata, but there isn't any value associated with the specified key,
// returns [ErrNotFound]. More information at [Metadata]'s Get method.
func Get(m any, key string) (any, error) {
data, err := GetMetadata(m)
if err != nil {
return nil, err
}
return data.Get(key)
}
// Sets a value of m, if it implements [Metadata] or [WithMetadata], otherwise returns
// otherwise returns [ErrNoMetadata].
//
// If the underlying metadata is [Immutable], returns [ErrImmutable]. See [Metadata]'s
// Set method for more information.
func Set(m any, key string, v any) error {
data, err := GetMetadata(m)
if err != nil {
return err
}
return data.Set(key, v)
}
// Deletes a value of m, if it implements [Metadata] or [WithMetadata], otherwise returns
// otherwise returns [ErrNoMetadata].
//
// If the underlying metadata is [Immutable], returns [ErrImmutable]. See [Metadata]'s
// Delete method for more information.
func Delete(m any, key string) error {
data, err := GetMetadata(m)
if err != nil {
return err
}
return data.Delete(key)
}
// Gets the underlying [Metadata] of m. If m implements [Metadata], returns it unchanged,
// otherwise uses the Metadata method if it implements [WithMetadata].
//
// If m doesn't implement any of the interfaces, returns [ErrNoMetadata].
func GetMetadata(m any) (Metadata, error) {
var data Metadata
if mt, ok := m.(Metadata); ok {
data = mt
} else if mfile, ok := m.(WithMetadata); ok {
data = mfile.Metadata()
} else {
return nil, ErrNoMetadata
}
return data, nil
}
// Types may implement this interface to add [Metadata] to their objects that can
// be easily accessed via [Get], [Set], [Delete] and [GetMetadata].
type WithMetadata interface {
// Returns the underlying [Metadata] of the type.
//
// If the [Metadata] is empty and/or the type doesn't have any data associated with
// it, implementations should return a empty [Metadata] (such as Map(map[string]any{}))
// and should never return a nil interface.
Metadata() Metadata
}
// Minimal interface for the Metadata data. This data is used to easily pass information
// between [plugin.Plugin]'s files and file systems.
//
// Implementations of this interface can store their metadata in any way possible,
// may it be with a simple underlying map[string]any or a network accessed storage.
//
// Plugins may add prefixed to their keys to minimize conflicts between other plugins'
// data. The convention for key strings is "<prefix>.<key-name>", all lowercase and
// kabeb-cased.
//
// Other objects and interfaces, such as [fs.FS] and [fs.File] may implement this
// interface directly or use the [WithMetadata] interface.
//
// [TypedMetadata] may also be implemented to optimize calls via [GetTyped].
type Metadata interface {
// Gets the value of the specified key.
//
// Implementations should return [ErrNotFound] if the provided key doesn't have
// any associated value with it.
Get(key string) (any, error)
// Sets the value of the specified key.
//
// If the key cannot be created or have it's underlying value changed,
// implementations should return [ErrImmutable].
//
// If the type of v is different from the stored value, implementations may return
// [ErrInvalidType] if they can't accept different types.
Set(key string, v any) error
// Deletes the key from metadata. Implementations that cannot delete the key directly,
// should set the value a appropriated zero or implementations' default value for that key.
//
// If the key cannot be created or have it's underlying value changed,
// implementations should return [ErrImmutable].
Delete(key string) error
}
// Type adapter to allow the use of ordinary maps as [Metadata] implementations.
//
// If map is nil, Get always returns [ErrNotFound], Set and Delete return [ErrImmutable].
type Map map[string]any
func (m Map) Get(key string) (any, error) {
if m == nil {
return nil, ErrNotFound
}
if v, ok := m[key]; ok {
return v, nil
} else {
return nil, ErrNotFound
}
}
func (m Map) Set(key string, v any) error {
if m == nil {
return ErrImmutable
}
m[key] = v
return nil
}
func (m Map) Delete(key string) error {
if m == nil {
return ErrImmutable
}
delete(m, key)
return nil
}
// Joins multiple [Metadata] objects together so their values can be easily
// accessed using just one call.
//
// [Get]:
// Iterates over all Metadatas until it finds one that returns a nil-error.
//
// [Set]:
// Sets the specified key on all underlying Metadatas. Ignores errors.
//
// [Delete]:
// Deletes the specified key on all underlying Metadatas. Ignores errors.
func Join(ms ...Metadata) Metadata {
ms = append([]Metadata{Map(make(map[string]any))}, ms...)
return joined(ms)
}
type joined []Metadata
func (m joined) Get(key string) (any, error) {
for _, m := range m {
v, err := m.Get(key)
if err == nil {
return v, nil
}
}
return nil, ErrNotFound
}
func (m joined) Set(key string, v any) error {
for _, m := range m {
_ = m.Set(key, v)
}
return nil
}
func (m joined) Delete(key string) error {
for _, m := range m {
_ = m.Delete(key)
}
return nil
}
type immutable struct{ Metadata }
// Converts all keys from m to immutable. All calls to Set and Delete
// are responded with [ErrImmutable].
func Immutable(m Metadata) Metadata {
return &immutable{m}
}
func (m *immutable) Set(key string, v any) error {
return ErrImmutable
}
func (m *immutable) Delete(key string) error {
return ErrImmutable
}

136
metadata/typed.go Normal file
View File

@@ -0,0 +1,136 @@
// Copyright 2025-present Gustavo "Guz" L. de Mello
// Copyright 2025-present The Lored.dev Contributors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package metadata
// TypedMetadata expands the [Metadata] interface to add helper methods for
// Go's primitive types.
//
// [GetTyped] uses this interface for optimization.
type TypedMetadata interface {
Metadata
GetBool(key string) (bool, error)
GetString(key string) (string, error)
GetInt(key string) (int, error)
GetInt8(key string) (int8, error)
GetInt16(key string) (int16, error)
GetInt32(key string) (int32, error)
GetInt64(key string) (int64, error)
GetUint(key string) (uint, error)
GetUint8(key string) (uint8, error)
GetUint16(key string) (uint16, error)
GetUint32(key string) (uint32, error)
GetUint64(key string) (uint64, error)
GetUintptr(key string) (uintptr, error)
GetByte(key string) (byte, error)
GetRune(key string) (rune, error)
GetFloat32(key string) (float32, error)
GetFloat64(key string) (float64, error)
GetComplex64(key string) (complex64, error)
GetComplex128(key string) (complex128, error)
}
func Typed(m Metadata) TypedMetadata {
if m, ok := m.(TypedMetadata); ok {
return m
}
return &typedMetadata{m}
}
type typedMetadata struct{ Metadata }
func (m *typedMetadata) GetBool(key string) (bool, error) {
return GetTyped[bool](m, key)
}
func (m *typedMetadata) GetString(key string) (string, error) {
return GetTyped[string](m, key)
}
func (m *typedMetadata) GetInt(key string) (int, error) {
return GetTyped[int](m, key)
}
func (m *typedMetadata) GetInt8(key string) (int8, error) {
return GetTyped[int8](m, key)
}
func (m *typedMetadata) GetInt16(key string) (int16, error) {
return GetTyped[int16](m, key)
}
func (m *typedMetadata) GetInt32(key string) (int32, error) {
return GetTyped[int32](m, key)
}
func (m *typedMetadata) GetInt64(key string) (int64, error) {
return GetTyped[int64](m, key)
}
func (m *typedMetadata) GetUint(key string) (uint, error) {
return GetTyped[uint](m, key)
}
func (m *typedMetadata) GetUint8(key string) (uint8, error) {
return GetTyped[uint8](m, key)
}
func (m *typedMetadata) GetUint16(key string) (uint16, error) {
return GetTyped[uint16](m, key)
}
func (m *typedMetadata) GetUint32(key string) (uint32, error) {
return GetTyped[uint32](m, key)
}
func (m *typedMetadata) GetUint64(key string) (uint64, error) {
return GetTyped[uint64](m, key)
}
func (m *typedMetadata) GetUintptr(key string) (uintptr, error) {
return GetTyped[uintptr](m, key)
}
func (m *typedMetadata) GetByte(key string) (byte, error) {
return GetTyped[byte](m, key)
}
func (m *typedMetadata) GetRune(key string) (rune, error) {
return GetTyped[rune](m, key)
}
func (m *typedMetadata) GetFloat32(key string) (float32, error) {
return GetTyped[float32](m, key)
}
func (m *typedMetadata) GetFloat64(key string) (float64, error) {
return GetTyped[float64](m, key)
}
func (m *typedMetadata) GetComplex64(key string) (complex64, error) {
return GetTyped[complex64](m, key)
}
func (m *typedMetadata) GetComplex128(key string) (complex128, error) {
return GetTyped[complex128](m, key)
}

44
plugin/group.go Normal file
View File

@@ -0,0 +1,44 @@
// Copyright 2025-present Gustavo "Guz" L. de Mello
// Copyright 2025-present The Lored.dev Contributors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package plugin
const pluginGroupName = "blogo-plugingroup-group"
type Group interface {
Plugin
WithPlugins
Plugins() []Plugin
}
type pluginGroup struct {
plugins []Plugin
}
func NewGroup(plugins ...Plugin) Group {
return &pluginGroup{plugins}
}
func (p *pluginGroup) Name() string {
return pluginGroupName
}
func (p *pluginGroup) Use(plugin Plugin) {
p.plugins = append(p.plugins, plugin)
}
func (p *pluginGroup) Plugins() []Plugin {
return p.plugins
}

45
plugin/plugin.go Normal file
View File

@@ -0,0 +1,45 @@
// Copyright 2025-present Gustavo "Guz" L. de Mello
// Copyright 2025-present The Lored.dev Contributors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package plugin
import (
"io"
"io/fs"
)
type Plugin interface {
Name() string
}
type WithPlugins interface {
Plugin
Use(Plugin)
}
type Renderer interface {
Plugin
Render(src fs.File, out io.Writer) error
}
type Sourcer interface {
Plugin
Source() (fs.FS, error)
}
type ErrorHandler interface {
Plugin
Handle(error) (recovr any, handled bool)
}

View File

@@ -0,0 +1,300 @@
// Copyright 2025-present Gustavo "Guz" L. de Mello
// Copyright 2025-present The Lored.dev Contributors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package plugins
import (
"bytes"
"errors"
"io"
"io/fs"
"log/slog"
"forge.capytal.company/loreddev/blogo/plugin"
"forge.capytal.company/loreddev/x/tinyssert"
)
const bufferedMultiRendererName = "blogo-buffer-renderer"
func NewBufferedMultiRenderer(opts ...BufferedMultiRendererOpts) BufferedMultiRenderer {
opt := BufferedMultiRendererOpts{}
if len(opts) > 0 {
opt = opts[0]
}
if opt.Assertions == nil {
opt.Assertions = tinyssert.NewDisabledAssertions()
}
if opt.Logger == nil {
opt.Logger = slog.New(slog.NewTextHandler(io.Discard, &slog.HandlerOptions{}))
}
return &bufferedMultiRenderer{
plugins: []plugin.Renderer{},
assert: opt.Assertions,
log: opt.Logger,
}
}
type BufferedMultiRenderer interface {
plugin.Renderer
plugin.WithPlugins
}
type BufferedMultiRendererOpts struct {
Assertions tinyssert.Assertions
Logger *slog.Logger
}
type bufferedMultiRenderer struct {
plugins []plugin.Renderer
assert tinyssert.Assertions
log *slog.Logger
}
func (r *bufferedMultiRenderer) Name() string {
return bufferedMultiRendererName
}
func (r *bufferedMultiRenderer) Use(p plugin.Plugin) {
r.assert.NotNil(r.plugins, "Plugins slice needs to be not-nil")
r.assert.NotNil(r.log)
log := r.log.With(slog.String("plugin", p.Name()))
log.Debug("Adding plugin")
if p, ok := p.(plugin.Group); ok {
log.Debug("Plugin implements plugin.Group, using it's plugins")
for _, p := range p.Plugins() {
r.Use(p)
}
}
if p, ok := p.(plugin.Renderer); ok {
log.Debug("Adding plugin")
r.plugins = append(r.plugins, p)
} else {
log.Warn("Plugin does not implement Renderer, ignoring")
}
}
func (r *bufferedMultiRenderer) Render(src fs.File, w io.Writer) error {
r.assert.NotNil(r.plugins, "Plugins slice needs to be not-nil")
r.assert.NotNil(r.log)
log := r.log.With()
log.Debug("Creating buffered file")
bf := newBufferedFile(src)
var buf bytes.Buffer
for _, p := range r.plugins {
log := log.With(slog.String("plugin", p.Name()))
log.Debug("Trying to render with plugin")
err := p.Render(bf, &buf)
if err == nil {
log.Debug("Successfully rendered with plugin")
break
}
log.Debug("Unable to render with plugin, resetting file and writer")
if err = bf.Reset(); err != nil {
log.Error("Failed to reset file", slog.String("err", err.Error()))
return errors.Join(errors.New("failed to reset buffered file"), err)
}
buf.Reset()
}
log.Debug("Copying response to final writer")
if _, err := io.Copy(w, &buf); err != nil {
log.Error("Failed to copy response to final writer")
return errors.Join(errors.New("failed to copy response to final writer"), err)
}
return nil
}
func newBufferedFile(src fs.File) bufferedFile {
var buf bytes.Buffer
r := io.TeeReader(src, &buf)
if d, ok := src.(fs.ReadDirFile); ok {
return &bufDirFile{
file: d,
buffer: &buf,
reader: r,
entries: []fs.DirEntry{},
eof: false,
n: 0,
}
}
return &bufFile{
file: src,
buffer: &buf,
reader: r,
}
}
type bufferedFile interface {
fs.File
Reset() error
}
type bufFile struct {
file fs.File
buffer *bytes.Buffer
reader io.Reader
}
func (f *bufFile) Read(p []byte) (int, error) {
return f.reader.Read(p)
}
func (f *bufFile) Close() error {
return nil
}
func (f *bufFile) Stat() (fs.FileInfo, error) {
return f.file.Stat()
}
func (f *bufFile) Reset() error {
_, err := io.ReadAll(f.reader)
if err != nil {
return err
}
var buf bytes.Buffer
r := io.TeeReader(f.buffer, &buf)
f.buffer = &buf
f.reader = r
return nil
}
type bufDirFile struct {
file fs.ReadDirFile
buffer *bytes.Buffer
reader io.Reader
entries []fs.DirEntry
eof bool
n int
}
func (f *bufDirFile) Read(p []byte) (int, error) {
return f.reader.Read(p)
}
func (f *bufDirFile) Close() error {
return nil
}
func (f *bufDirFile) Stat() (fs.FileInfo, error) {
return f.file.Stat()
}
func (f *bufDirFile) ReadDir(n int) ([]fs.DirEntry, error) {
start, end := f.n, f.n+n
var err error
// If EOF is true, it means we already read all the content from the
// source directory, so we can use the entries slice directly. Otherwise, we
// may need to read more from the source directly if the provided end index
// is bigger than what we already have.
if end > len(f.entries) && !f.eof {
e, err := f.file.ReadDir(n)
if e != nil {
// Add the entries to our buffer so we can access them even after a reset.
f.entries = append(f.entries, e...)
}
if err != nil && !errors.Is(err, io.EOF) {
return []fs.DirEntry{}, err
}
// If we reached EOF, we don't need to call the source directory anymore
// and can just use the slice directly
if errors.Is(err, io.EOF) {
f.eof = true
}
}
// Reading all contents from the directory needs us to have all values inside
// our buffer/slice, if EOF isn't already reached, we need to get the rest of
// the content from the source directory.
if n <= 0 && !f.eof {
e, err := f.file.ReadDir(n)
if e != nil {
f.entries = append(f.entries, e...)
}
if err != nil && !errors.Is(err, io.EOF) {
return []fs.DirEntry{}, err
}
if errors.Is(err, io.EOF) {
f.eof = true
}
}
if n <= 0 {
start, end = 0, len(f.entries)
} else if end > len(f.entries) {
end = len(f.entries)
err = io.EOF
}
e := f.entries[start:end]
f.n = end
return e, err
}
func (f *bufDirFile) Reset() error {
// To reset the ReadDir of the file, we pretty much just need to set
// the start offset to 0, so any subsequent reads will start at the first
// item, that is probably already buffered on f.entries.
f.n = 0
// Reset the Read buffer, the directory file implementation may have contents
// on it's Read function.
_, err := io.ReadAll(f.reader)
if err != nil {
return err
}
var buf bytes.Buffer
r := io.TeeReader(f.buffer, &buf)
f.buffer = &buf
f.reader = r
return nil
}

44
plugins/empty_sourcer.go Normal file
View File

@@ -0,0 +1,44 @@
// Copyright 2025-present Gustavo "Guz" L. de Mello
// Copyright 2025-present The Lored.dev Contributors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package plugins
import (
"io/fs"
"forge.capytal.company/loreddev/blogo/plugin"
)
const emptySourcerPluginName = "blogo-empty-sourcer"
type emptySourcer struct{}
func NewEmptySourcer() plugin.Sourcer {
return &emptySourcer{}
}
func (p *emptySourcer) Name() string {
return emptySourcerPluginName
}
func (p *emptySourcer) Source() (fs.FS, error) {
return emptyFS{}, nil
}
type emptyFS struct{}
func (f emptyFS) Open(name string) (fs.File, error) {
return nil, fs.ErrNotExist
}

179
plugins/folding_renderer.go Normal file
View File

@@ -0,0 +1,179 @@
// Copyright 2025-present Gustavo "Guz" L. de Mello
// Copyright 2025-present The Lored.dev Contributors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package plugins
import (
"bytes"
"fmt"
"io"
"io/fs"
"log/slog"
"forge.capytal.company/loreddev/blogo/plugin"
"forge.capytal.company/loreddev/x/tinyssert"
)
const foldingRendererPluginName = "blogo-foldingrenderer-renderer"
func NewFoldingRenderer(opts ...FoldingRendererOpts) FoldingRenderer {
opt := FoldingRendererOpts{}
if len(opts) > 0 {
opt = opts[0]
}
if opt.Assertions == nil {
opt.Assertions = tinyssert.NewDisabledAssertions()
}
if opt.Logger == nil {
opt.Logger = slog.New(slog.NewTextHandler(io.Discard, &slog.HandlerOptions{}))
}
return &foldingRenderer{
plugins: []plugin.Renderer{},
assert: opt.Assertions,
log: opt.Logger,
}
}
type FoldingRenderer interface {
plugin.WithPlugins
plugin.Renderer
}
type FoldingRendererOpts struct {
PanicOnInit bool
Assertions tinyssert.Assertions
Logger *slog.Logger
}
type foldingRenderer struct {
plugins []plugin.Renderer
assert tinyssert.Assertions
log *slog.Logger
}
func (r *foldingRenderer) Name() string {
return foldingRendererPluginName
}
func (r *foldingRenderer) Use(p plugin.Plugin) {
r.assert.NotNil(p)
r.assert.NotNil(r.plugins)
r.assert.NotNil(r.log)
log := r.log.With(slog.String("plugin", p.Name()))
if pr, ok := p.(plugin.Renderer); ok {
r.plugins = append(r.plugins, pr)
} else {
log.Error(fmt.Sprintf(
"Failed to add plugin %q, since it doesn't implement plugin.Renderer",
p.Name(),
))
}
}
func (r *foldingRenderer) Render(src fs.File, w io.Writer) error {
r.assert.NotNil(r.plugins)
r.assert.NotNil(r.log)
r.assert.NotNil(src)
r.assert.NotNil(w)
log := r.log.With()
if len(r.plugins) == 0 {
log.Debug("No renderers found, copying file contents to writer")
_, err := io.Copy(w, src)
return err
}
log.Debug("Creating folding file")
f, err := newFoldignFile(src)
if err != nil {
log.Error("Failed to create folding file", slog.String("err", err.Error()))
return err
}
for _, p := range r.plugins {
log := log.With(slog.String("plugin", p.Name()))
log.Debug("Rendering with plugin")
err := p.Render(f, f)
if err != nil {
log.Error("Failed to render with plugin", slog.String("err", err.Error()))
return err
}
log.Debug("Folding file to next render")
if err := f.Fold(); err != nil {
log.Error("Failed to fold file", slog.String("err", err.Error()))
return err
}
}
log.Debug("Writing final file to Writer")
_, err = io.Copy(w, f)
return err
}
type foldingFile struct {
fs.File
read *bytes.Buffer
writer *bytes.Buffer
}
func newFoldignFile(f fs.File) (*foldingFile, error) {
var r, w bytes.Buffer
if _, err := io.Copy(&r, f); err != nil {
return nil, err
}
if err := f.Close(); err != nil {
return nil, err
}
return &foldingFile{File: f, read: &r, writer: &w}, nil
}
func (f *foldingFile) Close() error {
return nil
}
func (f *foldingFile) Read(p []byte) (int, error) {
return f.read.Read(p)
}
func (f *foldingFile) Write(p []byte) (int, error) {
return f.writer.Write(p)
}
func (f *foldingFile) Fold() error {
f.read.Reset()
if _, err := io.Copy(f.read, f.writer); err != nil {
return err
}
f.writer.Reset()
return nil
}

2
plugins/gitea/TODO.md Normal file
View File

@@ -0,0 +1,2 @@
- [ ] Handle symlinks
- [ ] Handle submodules

249
plugins/gitea/client.go Normal file
View File

@@ -0,0 +1,249 @@
// By contributing to, or using this source code, you agree with the terms of the
// MIT-style licensed that can be found below:
//
// Copyright (c) 2025-present Gustavo "Guz" L. de Mello
// Copyright (c) 2025-present The Lored.dev Contributors
// Copyright (c) 2016 The Gitea Authors
// Copyright (c) 2014 The Gogs Authors
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
// THE SOFTWARE.
// Sections of the contents of this file were sourced from the official Gitea SDK for Go,
// which can be found at https://gitea.com/gitea/go-sdk and is licensed under a MIT-style
// licensed stated at the start of this file.
package gitea
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"time"
)
type client struct {
endpoint string
http *http.Client
}
func newClient(endpoint string, http *http.Client) *client {
return &client{endpoint: endpoint, http: http}
}
func (c *client) GetContents(
owner, repo, ref, filepath string,
) (*contentsResponse, *http.Response, error) {
data, res, err := c.get(
fmt.Sprintf("/repos/%s/%s/contents/%s?ref=%s", owner, repo, filepath, url.QueryEscape(ref)),
)
if err != nil {
return &contentsResponse{}, res, err
}
file := new(contentsResponse)
if err := json.Unmarshal(data, &file); err != nil {
return &contentsResponse{}, res, errors.Join(
errors.New("failed to parse JSON response from API"),
err,
)
}
return file, res, nil
}
func (c *client) ListContents(
owner, repo, ref, filepath string,
) ([]*contentsResponse, *http.Response, error) {
endpoint := fmt.Sprintf(
"/repos/%s/%s/contents/%s?ref=%s",
owner,
repo,
filepath,
url.QueryEscape(ref),
)
if filepath == "" || filepath == "." {
endpoint = fmt.Sprintf(
"/repos/%s/%s/contents?ref=%s",
owner,
repo,
url.QueryEscape(ref),
)
}
data, res, err := c.get(endpoint)
if err != nil {
return []*contentsResponse{}, res, err
}
directory := make([]*contentsResponse, 0)
if err := json.Unmarshal(data, &directory); err != nil {
return []*contentsResponse{}, res, errors.Join(
errors.New("failed to parse JSON response from API"),
err,
)
}
return directory, res, nil
}
func (c *client) GetSingleCommit(user, repo, commitID string) (*commit, *http.Response, error) {
data, res, err := c.get(
fmt.Sprintf("/repos/%s/%s/git/commits/%s", user, repo, commitID),
)
if err != nil {
return &commit{}, res, err
}
var commit *commit
if err := json.Unmarshal(data, commit); err != nil {
return commit, res, errors.Join(
errors.New("failed to parse JSON response from API"),
err,
)
}
return commit, res, err
}
func (c *client) GetFileReader(
owner, repo, ref, filepath string,
resolveLFS ...bool,
) (io.ReadCloser, *http.Response, error) {
if len(resolveLFS) != 0 && resolveLFS[0] {
return c.getResponseReader(
fmt.Sprintf(
"/repos/%s/%s/media/%s?ref=%s",
owner,
repo,
filepath,
url.QueryEscape(ref),
),
)
}
return c.getResponseReader(
fmt.Sprintf(
"/repos/%s/%s/raw/%s?ref=%s",
owner,
repo,
filepath,
url.QueryEscape(ref),
),
)
}
func (c *client) get(path string) ([]byte, *http.Response, error) {
body, res, err := c.getResponseReader(path)
if err != nil {
return nil, res, err
}
defer body.Close()
data, err := io.ReadAll(body)
if err != nil {
return nil, res, err
}
return data, res, err
}
func (c *client) getResponseReader(path string) (io.ReadCloser, *http.Response, error) {
res, err := c.http.Get(c.endpoint + path)
if err != nil {
return nil, nil, errors.Join(errors.New("failed to request"), err)
}
data, err := statusCodeToErr(res)
if err != nil {
return io.NopCloser(bytes.NewReader(data)), res, err
}
return res.Body, res, err
}
func statusCodeToErr(resp *http.Response) (body []byte, err error) {
if resp.StatusCode/100 == 2 {
return nil, nil
}
defer resp.Body.Close()
data, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("body read on HTTP error %d: %v", resp.StatusCode, err)
}
errMap := make(map[string]interface{})
if err = json.Unmarshal(data, &errMap); err != nil {
return data, fmt.Errorf(
"Unknown API Error: %d Request Path: '%s'\nResponse body: '%s'",
resp.StatusCode,
resp.Request.URL.Path,
string(data),
)
}
if msg, ok := errMap["message"]; ok {
return data, fmt.Errorf("%v", msg)
}
return data, fmt.Errorf("%s: %s", resp.Status, string(data))
}
type contentsResponse struct {
Name string `json:"name"`
Path string `json:"path"`
SHA string `json:"sha"`
LastCommitSha string `json:"last_commit_sha"`
// NOTE: can be "file", "dir", "symlink" or "submodule"
Type string `json:"type"`
Size int64 `json:"size"`
// NOTE: populated just when `type` is "file"
Encoding *string `json:"encoding"`
// NOTE: populated just when `type` is "file"
Content *string `json:"content"`
// NOTE: populated just when `type` is "link"
Target *string `json:"target"`
URL *string `json:"url"`
HTMLURL *string `json:"html_url"`
GitURL *string `json:"git_url"`
DownloadURL *string `json:"download_url"`
// NOTE: populated just when `type` is "submodule"
SubmoduleGitURL *string `json:"submodule_giit_url"`
Links *fileLinksResponse `json:"_links"`
}
type fileLinksResponse struct {
Self *string `json:"self"`
GitURL *string `json:"git"`
HTMLURL *string `json:"html"`
}
type commit struct {
URL string `json:"url"`
SHA string `json:"sha"`
Created time.Time `json:"created"`
}

348
plugins/gitea/fs.go Normal file
View File

@@ -0,0 +1,348 @@
// Copyright 2025-present Gustavo "Guz" L. de Mello
// Copyright 2025-present The Lored.dev Contributors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package gitea
import (
"bytes"
"encoding/base64"
"errors"
"io"
"io/fs"
"net/http"
"os"
"path"
"slices"
"syscall"
"time"
"forge.capytal.company/loreddev/blogo/metadata"
)
type repositoryFS struct {
metadata map[string]any
owner string
repo string
ref string
client *client
}
func newRepositoryFS(owner, repo, ref string, client *client) fs.FS {
return &repositoryFS{
owner: owner,
repo: repo,
ref: ref,
client: client,
}
}
func (fsys *repositoryFS) Metadata() metadata.Metadata {
// TODO: Properly implement metadata with contents from the API
if fsys.metadata == nil || (fsys.metadata != nil && len(fsys.metadata) == 0) {
m := map[string]any{}
m["gitea.owner"] = fsys.owner
m["gitea.repository"] = fsys.repo
if fsys.ref != "" {
m["gitea.ref"] = fsys.ref
}
fsys.metadata = m
}
return metadata.Map(fsys.metadata)
}
func (fsys *repositoryFS) Open(name string) (fs.File, error) {
if !fs.ValidPath(name) {
return nil, &fs.PathError{Op: "open", Path: name, Err: fs.ErrInvalid}
}
file, _, err := fsys.client.GetContents(fsys.owner, fsys.repo, fsys.ref, name)
if err == nil {
return &repositoryFile{
contentsResponse: *file,
owner: fsys.owner,
repo: fsys.repo,
ref: fsys.ref,
client: fsys.client,
contents: nil,
}, nil
}
// If previous call returned a error, it may be because the file is a directory,
// so we will call from it's parent directory to be able to get it's metadata.
path := path.Dir(name)
if path == "." {
path = ""
}
list, res, err := fsys.client.ListContents(fsys.owner, fsys.repo, fsys.ref, path)
if err != nil {
if res.StatusCode == http.StatusUnauthorized {
return nil, &fs.PathError{Op: "open", Path: name, Err: fs.ErrPermission}
} else if res.StatusCode == http.StatusNotFound {
return nil, &fs.PathError{Op: "open", Path: name, Err: fs.ErrNotExist}
}
return nil, &fs.PathError{Op: "open", Path: name, Err: err}
}
// If the function is being called to open the root directory, return the
// repository as a root directory. We are returning it here since we can get
// a SHA of the past returned files.
if name == "." {
sha := ""
if len(list) > 0 {
sha = list[0].LastCommitSha
}
return &repositoryDirFile{repositoryFile{
contentsResponse: contentsResponse{
Name: fsys.repo,
Path: ".",
SHA: sha,
LastCommitSha: sha,
Type: "dir",
},
owner: fsys.owner,
repo: fsys.repo,
ref: fsys.ref,
client: fsys.client,
contents: nil,
}, 0}, nil
}
i := slices.IndexFunc(list, func(i *contentsResponse) bool {
return i.Path == name
})
if i == -1 {
return nil, &fs.PathError{Op: "open", Path: name, Err: fs.ErrNotExist}
}
dir := list[i]
if dir.Type != "dir" {
return nil, &fs.PathError{
Op: "open",
Path: name,
Err: errors.New("unexpected, directory found is not of type 'dir'"),
}
}
f := &repositoryFile{
contentsResponse: *dir,
owner: fsys.owner,
repo: fsys.repo,
ref: fsys.ref,
client: fsys.client,
contents: nil,
}
return &repositoryDirFile{*f, 0}, nil
}
// Implements fs.File to represent a remote file in the repository. The contents of
// the file are filled on the first Read call, reusing the base64-encoded
// *contentsResponse.Content if available, if not, the file calls the API to retrieve
// the raw contents.
//
// To prevent possible content changes after this object has been initialized, if none
// ref is provided, it uses the *contentsResponse.LastCommitSha as a ref.
type repositoryFile struct {
contentsResponse
metadata map[string]any
owner string
repo string
ref string
client *client
contents io.ReadCloser
}
func (f *repositoryFile) Metadata() metadata.Metadata {
// TODO: Properly implement metadata with contents from the API
if f.metadata == nil || (f.metadata != nil && len(f.metadata) == 0) {
m := map[string]any{}
m["gitea.owner"] = f.owner
m["gitea.repository"] = f.repo
if f.ref != "" {
m["gitea.ref"] = f.ref
}
f.metadata = m
}
return metadata.Map(f.metadata)
}
func (f *repositoryFile) Stat() (fs.FileInfo, error) {
return &repositoryFileInfo{*f}, nil
}
func (f *repositoryFile) Read(p []byte) (int, error) {
var err error
if f.contents == nil && f.Type == "file" {
f.contents, err = f.getFileContents()
}
if err != nil {
return 0, errors.Join(errors.New("failed to fetch file contents from API"), err)
}
return f.contents.Read(p)
}
func (f *repositoryFile) Close() error {
return f.contents.Close()
}
func (f *repositoryFile) getFileContents() (io.ReadCloser, error) {
if *f.Content != "" && f.Encoding != nil && *f.Encoding == "base64" {
b, err := base64.StdEncoding.DecodeString(*f.Content)
if err == nil {
return io.NopCloser(bytes.NewReader(b)), nil
}
}
ref := f.ref
if ref == "" {
ref = f.contentsResponse.LastCommitSha
}
r, _, err := f.client.GetFileReader(f.owner, f.repo, ref, f.Path, true)
return r, err
}
// Implements fs.ReadDirFile for the underlying 'repositoryFile'.
// 'repositoryFile' should be of type "dir", and not a list of said directory
// content.
type repositoryDirFile struct {
repositoryFile
n int
}
func (f *repositoryDirFile) Read(p []byte) (int, error) {
return 0, io.EOF
}
func (f *repositoryDirFile) Close() error {
return nil
}
func (f *repositoryDirFile) ReadDir(n int) ([]fs.DirEntry, error) {
list, _, err := f.client.ListContents(f.owner, f.repo, f.ref, f.Path)
if err != nil {
return []fs.DirEntry{}, err
}
start, end := f.n, f.n+n
if n <= 0 {
start, end = 0, len(list)
} else if end > len(list) {
end = len(list)
err = io.EOF
}
list = list[start:end]
entries := make([]fs.DirEntry, len(list))
for i, v := range list {
entries[i] = &repositoryDirEntry{repositoryFile{
contentsResponse: *v,
owner: f.owner,
repo: f.repo,
ref: f.ref,
client: f.client,
}}
}
f.n = end
return entries, err
}
// Implements fs.DirEntry for the embedded 'repositoryFile'
type repositoryDirEntry struct {
repositoryFile
}
func (e *repositoryDirEntry) Name() string {
i, _ := e.Info()
return i.Name()
}
func (e *repositoryDirEntry) IsDir() bool {
i, _ := e.Info()
return i.IsDir()
}
func (e *repositoryDirEntry) Type() fs.FileMode {
i, _ := e.Info()
return i.Mode().Type()
}
func (e *repositoryDirEntry) Info() (fs.FileInfo, error) {
return &repositoryFileInfo{e.repositoryFile}, nil
}
// Implements fs.FileInfo, getting information from the embedded 'repositoryFile'
type repositoryFileInfo struct {
repositoryFile
}
func (fi *repositoryFileInfo) Name() string {
return fi.contentsResponse.Name
}
func (fi *repositoryFileInfo) Size() int64 {
return fi.contentsResponse.Size
}
func (fi *repositoryFileInfo) Mode() fs.FileMode {
if fi.Type == "symlink" {
return os.FileMode(fs.ModeSymlink | syscall.S_IRUSR | syscall.S_IRGRP | syscall.S_IROTH)
} else if fi.IsDir() {
return os.FileMode(fs.ModeDir | syscall.S_IRUSR | syscall.S_IRGRP | syscall.S_IROTH)
}
return os.FileMode(syscall.S_IRUSR | syscall.S_IRGRP | syscall.S_IROTH)
}
func (fi *repositoryFileInfo) ModTime() time.Time {
commit, _, err := fi.client.GetSingleCommit(fi.owner, fi.repo, fi.LastCommitSha)
if err != nil {
return time.Time{}
}
return commit.Created
}
func (fi *repositoryFileInfo) IsDir() bool {
return fi.Type == "dir"
}
func (fi *repositoryFileInfo) Sys() any {
return nil
}

88
plugins/gitea/gitea.go Normal file
View File

@@ -0,0 +1,88 @@
// Copyright 2025-present Gustavo "Guz" L. de Mello
// Copyright 2025-present The Lored.dev Contributors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package gitea
import (
"fmt"
"io/fs"
"net/http"
"net/url"
"strings"
"forge.capytal.company/loreddev/blogo/plugin"
)
const pluginName = "blogo-gitea-sourcer"
type p struct {
client *client
owner string
repo string
ref string
}
type Opts struct {
HTTPClient *http.Client
Ref string
}
func New(owner, repo, apiUrl string, opts ...Opts) plugin.Plugin {
opt := Opts{}
if len(opts) > 0 {
opt = opts[0]
}
if opt.HTTPClient == nil {
opt.HTTPClient = http.DefaultClient
}
u, err := url.Parse(apiUrl)
if err != nil {
panic(
fmt.Sprintf(
"%s: %q is not a valid URL. Err: %q",
pluginName,
apiUrl,
err.Error(),
),
)
}
if u.Path == "" || u.Path == "/" {
u.Path = "/api/v1"
} else {
u.Path = strings.TrimSuffix(u.Path, "/api/v1")
}
client := newClient(u.String(), opt.HTTPClient)
return &p{
client: client,
owner: owner,
repo: repo,
ref: opt.Ref,
}
}
func (p *p) Name() string {
return pluginName
}
func (p *p) Source() (fs.FS, error) {
return newRepositoryFS(p.owner, p.repo, p.ref, p.client), nil
}

View File

@@ -0,0 +1,32 @@
package gitea_test
import (
"io"
"testing"
"forge.capytal.company/loreddev/blogo/plugin"
"forge.capytal.company/loreddev/blogo/plugins/gitea"
)
func TestSource(t *testing.T) {
p := gitea.New("loreddev", "x", "https://forge.capytal.company")
s := p.(plugin.Sourcer)
fs, err := s.Source()
if err != nil {
t.Fatalf("Failed to source file system: %s %v", err.Error(), err)
}
file, err := fs.Open("blogo/LICENSE")
if err != nil {
t.Fatalf("Failed to open file: %s %v", err.Error(), err)
}
contents, err := io.ReadAll(file)
if err != nil {
t.Fatalf("Failed to read contents of file: %s %v", err.Error(), err)
}
t.Logf("Successfully read contents of file: %s", string(contents))
}

View File

@@ -0,0 +1,66 @@
// Copyright 2025-present Gustavo "Guz" L. de Mello
// Copyright 2025-present The Lored.dev Contributors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package plugins
import (
"fmt"
"log/slog"
"forge.capytal.company/loreddev/blogo/plugin"
)
const loggerErrorHandlerName = "blogo-loggererrorhandler-errorhandler"
func NewLoggerErrorHandler(logger *slog.Logger, level ...slog.Level) plugin.ErrorHandler {
l := slog.LevelError
if len(level) > 0 {
l = level[0]
}
if logger == nil {
panic(fmt.Sprintf("%s: Failed to construct LoggerErrorHandler, logger needs to be non-nil",
loggerErrorHandlerName))
}
return &loggerErrorHandler{logger: logger, level: l}
}
type loggerErrorHandler struct {
logger *slog.Logger
level slog.Level
}
func (h *loggerErrorHandler) Name() string {
return loggerErrorHandlerName
}
func (h *loggerErrorHandler) log(msg string, args ...any) {
switch h.level {
case slog.LevelDebug:
h.logger.Debug(msg, args...)
case slog.LevelInfo:
h.logger.Info(msg, args...)
case slog.LevelWarn:
h.logger.Warn(msg, args...)
default:
h.logger.Error(msg, args...)
}
}
func (h *loggerErrorHandler) Handle(err error) (recovr any, handled bool) {
h.log("BLOGO ERROR", slog.String("err", err.Error()))
return nil, true
}

View File

@@ -0,0 +1,60 @@
package markdown
import (
"errors"
"io"
"io/fs"
"strings"
"github.com/yuin/goldmark"
meta "github.com/yuin/goldmark-meta"
"github.com/yuin/goldmark/extension"
"github.com/yuin/goldmark/parser"
"github.com/yuin/goldmark/renderer"
"github.com/yuin/goldmark/text"
"forge.capytal.company/loreddev/blogo/plugin"
)
const pluginName = "blogo-markdown-renderer"
type p struct {
parser parser.Parser
renderer renderer.Renderer
}
func New() plugin.Plugin {
m := goldmark.New(
goldmark.WithExtensions(
extension.NewLinkify(),
meta.Meta,
),
)
return &p{
parser: m.Parser(),
renderer: m.Renderer(),
}
}
func (p *p) Name() string {
return pluginName
}
func (p *p) Render(f fs.File, w io.Writer) error {
stat, err := f.Stat()
if err != nil || !strings.HasSuffix(stat.Name(), ".md") {
return errors.New("does not support file")
}
src, err := io.ReadAll(f)
if err != nil {
return err
}
txt := text.NewReader(src)
ast := p.parser.Parse(txt)
return p.renderer.Render(w, src, ast)
}

View File

@@ -0,0 +1,111 @@
// Copyright 2025-present Gustavo "Guz" L. de Mello
// Copyright 2025-present The Lored.dev Contributors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package plugins
import (
"io"
"log/slog"
"forge.capytal.company/loreddev/blogo/plugin"
"forge.capytal.company/loreddev/x/tinyssert"
)
const multiErrorHandlerName = "blogo-multierrorhandler-errorhandler"
func NewMultiErrorHandler(opts ...MultiErrorHandlerOpts) MultiErrorHandler {
opt := MultiErrorHandlerOpts{}
if len(opts) > 0 {
opt = opts[0]
}
if opt.Assertions == nil {
opt.Assertions = tinyssert.NewDisabledAssertions()
}
if opt.Logger == nil {
opt.Logger = slog.New(slog.NewTextHandler(io.Discard, &slog.HandlerOptions{}))
}
return &multiErrorHandler{
handlers: []plugin.ErrorHandler{},
assert: opt.Assertions,
log: opt.Logger,
}
}
type MultiErrorHandler interface {
plugin.ErrorHandler
plugin.WithPlugins
}
type MultiErrorHandlerOpts struct {
Assertions tinyssert.Assertions
Logger *slog.Logger
}
type multiErrorHandler struct {
handlers []plugin.ErrorHandler
assert tinyssert.Assertions
log *slog.Logger
}
func (h *multiErrorHandler) Name() string {
return multiErrorHandlerName
}
func (h *multiErrorHandler) Use(p plugin.Plugin) {
h.assert.NotNil(h.handlers, "Error handlers slice should not be nil")
h.assert.NotNil(h.log)
log := h.log.With(slog.String("plugin", p.Name()))
log.Debug("Adding plugin")
if p, ok := p.(plugin.Group); ok {
log.Debug("Plugin is a group, using children plugins")
for _, p := range p.Plugins() {
h.Use(p)
}
}
if p, ok := p.(plugin.ErrorHandler); ok {
h.handlers = append(h.handlers, p)
} else {
log.Debug("Plugin does not implement ErrorHandler, ignoring")
}
}
func (h *multiErrorHandler) Handle(err error) (recovr any, handled bool) {
h.assert.NotNil(h.handlers, "Error handlers slice should not be nil")
h.assert.NotNil(h.log)
log := h.log.With(slog.String("err", err.Error()))
log.Debug("Handling error")
for _, handler := range h.handlers {
log := log.With(slog.String("plugin", handler.Name()))
log.Debug("Handling error with plugin")
recovr, ok := handler.Handle(err)
if ok {
log.Debug("Error successfully handled with plugin")
return recovr, ok
}
}
log.Debug("Failed to handle error with any plugin")
return nil, false
}

176
plugins/multi_renderer.go Normal file
View File

@@ -0,0 +1,176 @@
// Copyright 2025-present Gustavo "Guz" L. de Mello
// Copyright 2025-present The Lored.dev Contributors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package plugins
import (
"bytes"
"errors"
"fmt"
"io"
"io/fs"
"log/slog"
"forge.capytal.company/loreddev/blogo/plugin"
"forge.capytal.company/loreddev/x/tinyssert"
)
const multiRendererName = "blogo-multirenderer-renderer"
func NewMultiRenderer(opts ...MultiRendererOpts) MultiRenderer {
opt := MultiRendererOpts{}
if len(opts) > 0 {
opt = opts[0]
}
if opt.Assertions == nil {
opt.Assertions = tinyssert.NewDisabledAssertions()
}
if opt.Logger == nil {
opt.Logger = slog.New(slog.NewTextHandler(io.Discard, &slog.HandlerOptions{}))
}
return &multiRenderer{
plugins: []plugin.Renderer{},
assert: opt.Assertions,
log: opt.Logger,
}
}
type MultiRenderer interface {
plugin.Renderer
plugin.WithPlugins
}
type MultiRendererOpts struct {
Assertions tinyssert.Assertions
Logger *slog.Logger
}
type multiRenderer struct {
plugins []plugin.Renderer
assert tinyssert.Assertions
log *slog.Logger
}
func (r *multiRenderer) Name() string {
return multiRendererName
}
func (r *multiRenderer) Use(p plugin.Plugin) {
r.assert.NotNil(p)
r.assert.NotNil(r.plugins)
r.assert.NotNil(r.log)
log := r.log.With(slog.String("plugin", p.Name()))
if p, ok := p.(plugin.Group); ok {
log.Debug("Plugin is a group, using children plugins")
for _, p := range p.Plugins() {
r.Use(p)
}
}
if pr, ok := p.(plugin.Renderer); ok {
log.Debug("Added renderer plugin")
r.plugins = append(r.plugins, pr)
} else {
log.Error(fmt.Sprintf(
"Failed to add plugin %q, since it doesn't implement plugin.Renderer",
p.Name(),
))
}
}
func (r *multiRenderer) Render(src fs.File, w io.Writer) error {
r.assert.NotNil(r.plugins)
r.assert.NotNil(r.log)
r.assert.NotNil(src)
r.assert.NotNil(w)
log := r.log.With()
if len(r.plugins) == 0 {
log.Debug("No renderers found, copying file contents to writer")
_, err := io.Copy(w, src)
return err
}
mf := newMultiRendererFile(src)
for _, pr := range r.plugins {
log := log.With(slog.String("plugin", pr.Name()))
log.Debug("Trying to render with plugin")
err := pr.Render(src, w)
if err == nil {
break
}
log.Debug("Unable to render using plugin", slog.String("error", err.Error()))
log.Debug("Resetting file for next read")
if err := mf.Reset(); err != nil {
log.Error("Failed to reset file read offset", slog.String("error", err.Error()))
return errors.Join(fmt.Errorf("failed to reset file read offset"), err)
}
}
return nil
}
type multiRendererFile struct {
fs.File
buf *bytes.Buffer
reader io.Reader
}
func newMultiRendererFile(f fs.File) *multiRendererFile {
if _, ok := f.(io.Seeker); ok {
return &multiRendererFile{
File: f,
reader: f,
}
}
var buf bytes.Buffer
return &multiRendererFile{
File: f,
reader: io.TeeReader(f, &buf),
buf: &buf,
}
}
func (f *multiRendererFile) Read(p []byte) (int, error) {
return f.reader.Read(p)
}
func (f *multiRendererFile) Reset() error {
if s, ok := f.File.(io.Seeker); ok {
_, err := s.Seek(0, io.SeekStart)
return err
}
var buf bytes.Buffer
r := io.MultiReader(f.buf, f.File)
f.reader = io.TeeReader(r, &buf)
f.buf = &buf
return nil
}

176
plugins/multi_sourcer.go Normal file
View File

@@ -0,0 +1,176 @@
// Copyright 2025-present Gustavo "Guz" L. de Mello
// Copyright 2025-present The Lored.dev Contributors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package plugins
import (
"errors"
"fmt"
"io"
"io/fs"
"log/slog"
"forge.capytal.company/loreddev/blogo/metadata"
"forge.capytal.company/loreddev/blogo/plugin"
"forge.capytal.company/loreddev/x/tinyssert"
)
const multiSourcerName = "blogo-multisourcer-sourcer"
func NewMultiSourcer(opts ...MultiSourcerOpts) MultiSourcer {
opt := MultiSourcerOpts{}
if len(opts) > 0 {
opt = opts[0]
}
if opt.Assertions == nil {
opt.Assertions = tinyssert.NewDisabledAssertions()
}
if opt.Logger == nil {
opt.Logger = slog.New(slog.NewTextHandler(io.Discard, &slog.HandlerOptions{}))
}
return &multiSourcer{
plugins: []plugin.Sourcer{},
skipOnSourceError: opt.SkipOnSourceError,
skipOnFSError: opt.SkipOnFSError,
log: opt.Logger,
}
}
type MultiSourcer interface {
plugin.Sourcer
plugin.WithPlugins
}
type MultiSourcerOpts struct {
SkipOnSourceError bool
SkipOnFSError bool
Assertions tinyssert.Assertions
Logger *slog.Logger
}
type multiSourcer struct {
plugins []plugin.Sourcer
skipOnSourceError bool
skipOnFSError bool
assert tinyssert.Assertions
log *slog.Logger
}
func (s *multiSourcer) Name() string {
return multiSourcerName
}
func (s *multiSourcer) Use(p plugin.Plugin) {
s.assert.NotNil(p)
s.assert.NotNil(s.plugins)
s.assert.NotNil(s.log)
log := s.log.With(slog.String("plugin", p.Name()))
if p, ok := p.(plugin.Group); ok {
log.Debug("Plugin is a group, using children plugins")
for _, p := range p.Plugins() {
s.Use(p)
}
}
if plg, ok := p.(plugin.Sourcer); ok {
log.Debug("Added sourcer plugin")
s.plugins = append(s.plugins, plg)
} else {
log.Error(fmt.Sprintf(
"Failed to add plugin %q, since it doesn't implement plugin.Sourcer",
p.Name(),
))
}
}
func (s *multiSourcer) Source() (fs.FS, error) {
s.assert.NotNil(s.plugins)
s.assert.NotNil(s.log)
log := s.log.With()
fileSystems := []fs.FS{}
for _, ps := range s.plugins {
log = log.With(slog.String("plugin", ps.Name()))
log.Info("Sourcing file system of plugin")
f, err := ps.Source()
if err != nil && s.skipOnSourceError {
log.Warn(
"Failed to source file system of plugin, skipping",
slog.String("error", err.Error()),
)
} else if err != nil {
log.Error(
"Failed to source file system of plugin, returning error",
slog.String("error", err.Error()),
)
return f, err
}
fileSystems = append(fileSystems, f)
}
f := make([]fs.FS, len(fileSystems))
for i := range f {
f[i] = fileSystems[i]
}
return &multiSourcerFS{
fileSystems: f,
skipOnError: s.skipOnFSError,
}, nil
}
type multiSourcerFS struct {
fileSystems []fs.FS
skipOnError bool
}
func (pf *multiSourcerFS) Metadata() metadata.Metadata {
ms := []metadata.Metadata{}
for _, v := range pf.fileSystems {
if m, err := metadata.GetMetadata(v); err == nil {
ms = append(ms, m)
}
}
return metadata.Join(ms...)
}
func (mf *multiSourcerFS) Open(name string) (fs.File, error) {
for _, f := range mf.fileSystems {
file, err := f.Open(name)
if err != nil && !errors.Is(err, fs.ErrNotExist) && !mf.skipOnError {
return file, err
}
if err == nil {
return file, err
}
}
return nil, fs.ErrNotExist
}

View File

@@ -0,0 +1,132 @@
// Copyright 2025-present Gustavo "Guz" L. de Mello
// Copyright 2025-present The Lored.dev Contributors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package plugins
import (
"errors"
"html/template"
"io"
"io/fs"
"log/slog"
"net/http"
"forge.capytal.company/loreddev/blogo/core"
"forge.capytal.company/loreddev/blogo/plugin"
"forge.capytal.company/loreddev/x/tinyssert"
)
const notFoundErrorHandlerName = "blogo-notfounderrorhandler-errorhandler"
func NewNotFoundErrorHandler(
templt template.Template,
opts ...TemplateErrorHandlerOpts,
) plugin.ErrorHandler {
opt := TemplateErrorHandlerOpts{}
if len(opts) > 0 {
opt = opts[0]
}
if opt.Assertions == nil {
opt.Assertions = tinyssert.NewDisabledAssertions()
}
if opt.Logger == nil {
opt.Logger = slog.New(slog.NewTextHandler(io.Discard, &slog.HandlerOptions{}))
}
return &notFoundErrorHandler{
templt: templt,
assert: opt.Assertions,
log: opt.Logger,
}
}
type NotFoundErrorHandlerOpts struct {
Assertions tinyssert.Assertions
Logger *slog.Logger
}
type NotFoundErrorHandlerInfo struct {
Plugin string
Path string
FilePath string
Error error
ErrorMsg string
}
type notFoundErrorHandler struct {
templt template.Template
assert tinyssert.Assertions
log *slog.Logger
}
func (h *notFoundErrorHandler) Name() string {
return notFoundErrorHandlerName
}
func (h *notFoundErrorHandler) Handle(err error) (recovr any, handled bool) {
h.assert.NotNil(err, "Error should not be nil")
h.assert.NotNil(h.templt, "notFound should not be nil")
h.assert.NotNil(h.log)
log := h.log.With(slog.String("err", err.Error()))
var serr core.ServeError
if !errors.As(err, &serr) {
log.Debug("Error is not a core.ServeError, ignoring error")
return nil, false
}
log = h.log.With(slog.String("serveerr", serr.Error()))
var sourceErr core.SourceError
if !errors.As(serr.Err, &sourceErr) {
log.Debug("Error is not a core.SourceError, ignoring error")
return nil, false
}
log = h.log.With(slog.String("sourceerr", sourceErr.Error()))
pathErr, ok := sourceErr.Err.(*fs.PathError)
if !ok {
log.Debug("Error is not a *fs.PathError, ignoring error")
return nil, false
} else if pathErr.Err != fs.ErrNotExist {
log.Debug("Error is not fs.ErrNotExist, ignoring error")
return nil, false
}
log = h.log.With(slog.String("patherr", pathErr.Error()))
log.Debug("Handling error")
w, r := serr.Res, serr.Req
w.WriteHeader(http.StatusNotFound)
if err := h.templt.Execute(w, NotFoundErrorHandlerInfo{
Plugin: sourceErr.Sourcer.Name(),
Path: r.URL.Path,
FilePath: pathErr.Path,
Error: serr.Err,
ErrorMsg: serr.Err.Error(),
}); err != nil {
log.Error("Failed to execute notFound and respond error")
return nil, false
}
return nil, true
}

86
plugins/plain_text.go Normal file
View File

@@ -0,0 +1,86 @@
// Copyright 2025-present Gustavo "Guz" L. de Mello
// Copyright 2025-present The Lored.dev Contributors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package plugins
import (
"errors"
"fmt"
"io"
"io/fs"
"forge.capytal.company/loreddev/blogo/plugin"
"forge.capytal.company/loreddev/x/tinyssert"
)
const plainTextName = "blogo-plaintext-renderer"
func NewPlainText(opts ...PlainTextOpts) plugin.Renderer {
opt := PlainTextOpts{}
if len(opts) > 0 {
opt = opts[0]
}
if opt.Assertions == nil {
opt.Assertions = tinyssert.NewDisabledAssertions()
}
return &painText{
assert: opt.Assertions,
}
}
type PlainTextOpts struct {
Assertions tinyssert.Assertions
}
type painText struct {
assert tinyssert.Assertions
}
func (p *painText) Name() string {
return plainTextName
}
func (p *painText) Render(src fs.File, w io.Writer) error {
p.assert.NotNil(src)
p.assert.NotNil(w)
if d, ok := src.(fs.ReadDirFile); ok {
return p.renderDirectory(d, w)
}
_, err := io.Copy(w, src)
return err
}
func (p *painText) renderDirectory(f fs.ReadDirFile, w io.Writer) error {
es, err := f.ReadDir(-1)
if err != nil {
return err
}
for _, e := range es {
_, err := w.Write([]byte(fmt.Sprintf("%s\n", e.Name())))
if err != nil {
return errors.Join(
fmt.Errorf("failed to write directory file list, file %s", e.Name()),
err,
)
}
}
return nil
}

16
plugins/plugins.go Normal file
View File

@@ -0,0 +1,16 @@
// Copyright 2025-present Gustavo "Guz" L. de Mello
// Copyright 2025-present The Lored.dev Contributors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package plugins

186
plugins/prefixed_sourcer.go Normal file
View File

@@ -0,0 +1,186 @@
// Copyright 2025-present Gustavo "Guz" L. de Mello
// Copyright 2025-present The Lored.dev Contributors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package plugins
import (
"fmt"
"io"
"io/fs"
"log/slog"
"strings"
"forge.capytal.company/loreddev/blogo/metadata"
"forge.capytal.company/loreddev/blogo/plugin"
"forge.capytal.company/loreddev/x/tinyssert"
)
const prefixedSourcerName = "blogo-prefixedsourcer-sourcer"
func NewPrefixedSourcer(opts ...PrefixedSourcerOpts) PrefixedSourcer {
opt := PrefixedSourcerOpts{}
if len(opts) > 0 {
opt = opts[0]
}
if opt.PrefixSeparator == "" {
opt.PrefixSeparator = "/"
}
if opt.Assertions == nil {
opt.Assertions = tinyssert.NewDisabledAssertions()
}
if opt.Logger == nil {
opt.Logger = slog.New(slog.NewTextHandler(io.Discard, &slog.HandlerOptions{}))
}
return &prefixedSourcer{
plugins: map[string]plugin.Sourcer{},
prefixSeparator: opt.PrefixSeparator,
acceptDuplicated: opt.AcceptDuplicated,
skipOnSourceError: opt.SkipOnSourceError,
skipOnFSError: opt.SkipOnFSError,
assert: opt.Assertions,
log: opt.Logger,
}
}
type PrefixedSourcerOpts struct {
PrefixSeparator string
AcceptDuplicated bool
SkipOnSourceError bool
SkipOnFSError bool
Assertions tinyssert.Assertions
Logger *slog.Logger
}
type PrefixedSourcer interface {
plugin.Sourcer
plugin.WithPlugins
UseNamed(string, plugin.Plugin)
}
type prefixedSourcer struct {
plugins map[string]plugin.Sourcer
prefixSeparator string
acceptDuplicated bool
skipOnSourceError bool
skipOnFSError bool
assert tinyssert.Assertions
log *slog.Logger
}
func (s *prefixedSourcer) Name() string {
return prefixedSourcerName
}
func (s *prefixedSourcer) Use(plugin plugin.Plugin) {
s.UseNamed(plugin.Name(), plugin)
}
func (s *prefixedSourcer) UseNamed(prefix string, p plugin.Plugin) {
s.assert.NotZero(prefix, "Prefix of plugin should not be empty")
s.assert.NotNil(p)
s.assert.NotNil(s.plugins)
s.assert.NotNil(s.log)
log := s.log.With(slog.String("plugin", p.Name()), slog.String("prefix", prefix))
log.Debug("Adding plugin")
var sourcer plugin.Sourcer
if ps, ok := p.(plugin.Sourcer); ok {
sourcer = ps
} else {
log.Error(fmt.Sprintf(
"Failed to add plugin %q (with prefix %q), since it doesn't implement SourcerPlugin",
p.Name(), prefix,
))
return
}
if _, ok := s.plugins[prefix]; ok && !s.acceptDuplicated {
log.Error("Duplicated prefix, skipping plugin")
return
}
s.plugins[prefix] = sourcer
}
func (s *prefixedSourcer) Source() (fs.FS, error) {
s.assert.NotNil(s.plugins)
s.assert.NotNil(s.log)
log := s.log.With()
fileSystems := make(map[string]fs.FS, len(s.plugins))
for a, ps := range s.plugins {
log = log.With(slog.String("plugin", ps.Name()), slog.String("prefix", a))
log.Info("Sourcing file system of plugin")
f, err := ps.Source()
if err != nil && s.skipOnSourceError {
log.Warn("Failed to source file system of plugin, skipping",
slog.String("error", err.Error()))
} else if err != nil {
log.Error("Failed to source file system of plugin, returning error",
slog.String("error", err.Error()))
return f, err
}
fileSystems[a] = f
}
return &prefixedSourcerFS{
fileSystems: fileSystems,
prefixSeparator: s.prefixSeparator,
}, nil
}
type prefixedSourcerFS struct {
fileSystems map[string]fs.FS
prefixSeparator string
}
func (pf *prefixedSourcerFS) Metadata() metadata.Metadata {
ms := []metadata.Metadata{}
for _, v := range pf.fileSystems {
if m, err := metadata.GetMetadata(v); err == nil {
ms = append(ms, m)
}
}
return metadata.Join(ms...)
}
func (pf *prefixedSourcerFS) Open(name string) (fs.File, error) {
prefix, path, found := strings.Cut(name, pf.prefixSeparator)
if !found {
return nil, &fs.PathError{Op: "open", Path: name, Err: fs.ErrNotExist}
}
if f, ok := pf.fileSystems[prefix]; ok {
return f.Open(path)
}
return nil, &fs.PathError{Op: "open", Path: name, Err: fs.ErrNotExist}
}

72
plugins/priority_list.go Normal file
View File

@@ -0,0 +1,72 @@
// Copyright 2025-present Gustavo "Guz" L. de Mello
// Copyright 2025-present The Lored.dev Contributors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package plugins
import (
"cmp"
"slices"
"forge.capytal.company/loreddev/blogo/plugin"
)
const priorityGroupName = "blogo-prioritygroup-group"
func NewPriorityGroup(plugins ...plugin.Plugin) PriorityGroup {
return &priorityGroup{plugins}
}
type PriorityGroup interface {
plugin.WithPlugins
}
type priorityGroup struct {
plugins []plugin.Plugin
}
func (p *priorityGroup) Name() string {
return priorityGroupName
}
func (p *priorityGroup) Use(plugin plugin.Plugin) {
p.plugins = append(p.plugins, plugin)
}
func (p *priorityGroup) Plugins() []plugin.Plugin {
slices.SortStableFunc(p.plugins, func(a plugin.Plugin, b plugin.Plugin) int {
return cmp.Compare(p.getPriority(a, b), p.getPriority(b, a))
})
return p.plugins
}
func (p *priorityGroup) getPriority(plugin plugin.Plugin, cmp plugin.Plugin) int {
if plg, ok := plugin.(PluginWithDynamicPriority); ok {
return plg.Priority(cmp)
} else if plg, ok := plugin.(PluginWithPriority); ok {
return plg.Priority()
} else {
return 0
}
}
type PluginWithPriority interface {
plugin.Plugin
Priority() int
}
type PluginWithDynamicPriority interface {
plugin.Plugin
Priority(plugin.Plugin) int
}

View File

@@ -0,0 +1,106 @@
// Copyright 2025-present Gustavo "Guz" L. de Mello
// Copyright 2025-present The Lored.dev Contributors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package plugins
import (
"errors"
"html/template"
"io"
"log/slog"
"net/http"
"forge.capytal.company/loreddev/blogo/core"
"forge.capytal.company/loreddev/blogo/plugin"
"forge.capytal.company/loreddev/x/tinyssert"
)
const templateErrorHandlerName = "blogo-templateerrorhandler-errorhandler"
func NewTemplateErrorHandler(
templt template.Template,
opts ...TemplateErrorHandlerOpts,
) plugin.ErrorHandler {
opt := TemplateErrorHandlerOpts{}
if len(opts) > 0 {
opt = opts[0]
}
if opt.Assertions == nil {
opt.Assertions = tinyssert.NewDisabledAssertions()
}
if opt.Logger == nil {
opt.Logger = slog.New(slog.NewTextHandler(io.Discard, &slog.HandlerOptions{}))
}
return &templateErrorHandler{
templt: templt,
assert: opt.Assertions,
log: opt.Logger,
}
}
type TemplateErrorHandlerOpts struct {
Assertions tinyssert.Assertions
Logger *slog.Logger
}
type TemplateErrorHandlerInfo struct {
Path string
Error error
ErrorMsg string
}
type templateErrorHandler struct {
templt template.Template
assert tinyssert.Assertions
log *slog.Logger
}
func (h *templateErrorHandler) Name() string {
return templateErrorHandlerName
}
func (h *templateErrorHandler) Handle(err error) (recovr any, handled bool) {
h.assert.NotNil(err, "Error should not be nil")
h.assert.NotNil(h.templt, "Template should not be nil")
h.assert.NotNil(h.log)
log := h.log.With(slog.String("err", err.Error()))
var serr core.ServeError
if !errors.As(err, &serr) {
log.Debug("Error is not a core.ServeError, ignoring error")
return nil, false
}
log.Debug("Handling error")
w, r := serr.Res, serr.Req
w.WriteHeader(http.StatusInternalServerError)
if err := h.templt.Execute(w, TemplateErrorHandlerInfo{
Path: r.URL.Path,
Error: serr.Err,
ErrorMsg: serr.Err.Error(),
}); err != nil {
log.Error("Failed to execute template and respond error")
return nil, false
}
return nil, true
}