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:
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
.direnv
|
||||
.envrc
|
||||
202
LICENSE
Normal file
202
LICENSE
Normal 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
19
README.md
Normal 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
423
blogo.go
Normal 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
332
core/core.go
Normal 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
65
core/errors.go
Normal 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
27
flake.lock
generated
Normal 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
36
flake.nix
Normal 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
11
go.mod
Normal 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
10
go.sum
Normal 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
320
metadata/metadata.go
Normal 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
136
metadata/typed.go
Normal 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
44
plugin/group.go
Normal 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
45
plugin/plugin.go
Normal 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)
|
||||
}
|
||||
300
plugins/buffered_multi_renderer.go
Normal file
300
plugins/buffered_multi_renderer.go
Normal 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
44
plugins/empty_sourcer.go
Normal 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
179
plugins/folding_renderer.go
Normal 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
2
plugins/gitea/TODO.md
Normal file
@@ -0,0 +1,2 @@
|
||||
- [ ] Handle symlinks
|
||||
- [ ] Handle submodules
|
||||
249
plugins/gitea/client.go
Normal file
249
plugins/gitea/client.go
Normal 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
348
plugins/gitea/fs.go
Normal 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
88
plugins/gitea/gitea.go
Normal 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
|
||||
}
|
||||
32
plugins/gitea/gitea_test.go
Normal file
32
plugins/gitea/gitea_test.go
Normal 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))
|
||||
}
|
||||
66
plugins/logger_error_handler.go
Normal file
66
plugins/logger_error_handler.go
Normal 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
|
||||
}
|
||||
60
plugins/markdown/markdown.go
Normal file
60
plugins/markdown/markdown.go
Normal 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)
|
||||
}
|
||||
111
plugins/multi_error_handler.go
Normal file
111
plugins/multi_error_handler.go
Normal 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
176
plugins/multi_renderer.go
Normal 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
176
plugins/multi_sourcer.go
Normal 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
|
||||
}
|
||||
132
plugins/not_found_error_handler.go
Normal file
132
plugins/not_found_error_handler.go
Normal 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 ¬FoundErrorHandler{
|
||||
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
86
plugins/plain_text.go
Normal 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
16
plugins/plugins.go
Normal 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
186
plugins/prefixed_sourcer.go
Normal 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
72
plugins/priority_list.go
Normal 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
|
||||
}
|
||||
106
plugins/template_error_handler.go
Normal file
106
plugins/template_error_handler.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user