commit 8f3245684b7a06f00d4d0156b5a6c394fa82c22c Author: Gustavo L de Mello (Guz) Date: Wed Jan 29 11:24:10 2025 -0300 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 https://forge.capytal.company/loreddev/x/src/commit/1f823aa0998d400c5ece22ca7b5af07965deeaac diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f3bf9c3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.direnv +.envrc diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..57bc88a --- /dev/null +++ b/LICENSE @@ -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. + diff --git a/README.md b/README.md new file mode 100644 index 0000000..4272aa4 --- /dev/null +++ b/README.md @@ -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. diff --git a/blogo.go b/blogo.go new file mode 100644 index 0000000..21bd79b --- /dev/null +++ b/blogo.go @@ -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 +} diff --git a/core/core.go b/core/core.go new file mode 100644 index 0000000..54a6433 --- /dev/null +++ b/core/core.go @@ -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 +} diff --git a/core/errors.go b/core/errors.go new file mode 100644 index 0000000..d2cc1e5 --- /dev/null +++ b/core/errors.go @@ -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 +} diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..db7b727 --- /dev/null +++ b/flake.lock @@ -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 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..00e8944 --- /dev/null +++ b/flake.nix @@ -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 + ]; + }; + }); + }; +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..8ed10e6 --- /dev/null +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..a64ea2e --- /dev/null +++ b/go.sum @@ -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= diff --git a/metadata/metadata.go b/metadata/metadata.go new file mode 100644 index 0000000..4791db4 --- /dev/null +++ b/metadata/metadata.go @@ -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 ".", 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 +} diff --git a/metadata/typed.go b/metadata/typed.go new file mode 100644 index 0000000..5bd0895 --- /dev/null +++ b/metadata/typed.go @@ -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) +} diff --git a/plugin/group.go b/plugin/group.go new file mode 100644 index 0000000..7c0ec26 --- /dev/null +++ b/plugin/group.go @@ -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 +} diff --git a/plugin/plugin.go b/plugin/plugin.go new file mode 100644 index 0000000..334470d --- /dev/null +++ b/plugin/plugin.go @@ -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) +} diff --git a/plugins/buffered_multi_renderer.go b/plugins/buffered_multi_renderer.go new file mode 100644 index 0000000..a3bd780 --- /dev/null +++ b/plugins/buffered_multi_renderer.go @@ -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 +} diff --git a/plugins/empty_sourcer.go b/plugins/empty_sourcer.go new file mode 100644 index 0000000..23a4a08 --- /dev/null +++ b/plugins/empty_sourcer.go @@ -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 +} diff --git a/plugins/folding_renderer.go b/plugins/folding_renderer.go new file mode 100644 index 0000000..4ec5472 --- /dev/null +++ b/plugins/folding_renderer.go @@ -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 +} diff --git a/plugins/gitea/TODO.md b/plugins/gitea/TODO.md new file mode 100644 index 0000000..13ade74 --- /dev/null +++ b/plugins/gitea/TODO.md @@ -0,0 +1,2 @@ +- [ ] Handle symlinks +- [ ] Handle submodules diff --git a/plugins/gitea/client.go b/plugins/gitea/client.go new file mode 100644 index 0000000..5438fb6 --- /dev/null +++ b/plugins/gitea/client.go @@ -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"` +} diff --git a/plugins/gitea/fs.go b/plugins/gitea/fs.go new file mode 100644 index 0000000..b7fdfd0 --- /dev/null +++ b/plugins/gitea/fs.go @@ -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 +} diff --git a/plugins/gitea/gitea.go b/plugins/gitea/gitea.go new file mode 100644 index 0000000..d638229 --- /dev/null +++ b/plugins/gitea/gitea.go @@ -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 +} diff --git a/plugins/gitea/gitea_test.go b/plugins/gitea/gitea_test.go new file mode 100644 index 0000000..d0f6efa --- /dev/null +++ b/plugins/gitea/gitea_test.go @@ -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)) +} diff --git a/plugins/logger_error_handler.go b/plugins/logger_error_handler.go new file mode 100644 index 0000000..527da6b --- /dev/null +++ b/plugins/logger_error_handler.go @@ -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 +} diff --git a/plugins/markdown/markdown.go b/plugins/markdown/markdown.go new file mode 100644 index 0000000..cbb0dde --- /dev/null +++ b/plugins/markdown/markdown.go @@ -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) +} diff --git a/plugins/multi_error_handler.go b/plugins/multi_error_handler.go new file mode 100644 index 0000000..df6f16b --- /dev/null +++ b/plugins/multi_error_handler.go @@ -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 +} diff --git a/plugins/multi_renderer.go b/plugins/multi_renderer.go new file mode 100644 index 0000000..750f1ed --- /dev/null +++ b/plugins/multi_renderer.go @@ -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 +} diff --git a/plugins/multi_sourcer.go b/plugins/multi_sourcer.go new file mode 100644 index 0000000..954fa31 --- /dev/null +++ b/plugins/multi_sourcer.go @@ -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 +} diff --git a/plugins/not_found_error_handler.go b/plugins/not_found_error_handler.go new file mode 100644 index 0000000..6d0c90c --- /dev/null +++ b/plugins/not_found_error_handler.go @@ -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 +} diff --git a/plugins/plain_text.go b/plugins/plain_text.go new file mode 100644 index 0000000..dfeb99a --- /dev/null +++ b/plugins/plain_text.go @@ -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 +} diff --git a/plugins/plugins.go b/plugins/plugins.go new file mode 100644 index 0000000..d95b720 --- /dev/null +++ b/plugins/plugins.go @@ -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 diff --git a/plugins/prefixed_sourcer.go b/plugins/prefixed_sourcer.go new file mode 100644 index 0000000..0f5f604 --- /dev/null +++ b/plugins/prefixed_sourcer.go @@ -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} +} diff --git a/plugins/priority_list.go b/plugins/priority_list.go new file mode 100644 index 0000000..e72a1fa --- /dev/null +++ b/plugins/priority_list.go @@ -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 +} diff --git a/plugins/template_error_handler.go b/plugins/template_error_handler.go new file mode 100644 index 0000000..0df5515 --- /dev/null +++ b/plugins/template_error_handler.go @@ -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 +}