added mvac

This commit is contained in:
thomashamburg 2025-10-15 21:46:18 +02:00
parent 90b5a33ead
commit 77a5e61a31
31 changed files with 5067 additions and 0 deletions

2
.gitignore vendored
View File

@ -39,3 +39,5 @@ go.work
static.go static.go
rawdata rawdata
mva

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 26 KiB

5
mvac/Makefile Normal file
View File

@ -0,0 +1,5 @@
run:
go run .
build:
go build -tags prod .

73
mvac/basic/main.go Normal file
View File

@ -0,0 +1,73 @@
package main
import (
"crypto/rand"
"encoding/hex"
"fmt"
"log/slog"
"net/http"
"time"
"github.com/starfederation/datastar/sdk/go/datastar"
)
const (
cdn = "https://cdn.jsdelivr.net/gh/starfederation/datastar@develop/bundles/datastar.js"
port = 9001
)
func main() {
mux := http.NewServeMux()
style := "display:flex;flex-direction:column;background-color:oklch(25.3267% 0.015896 252.417568);height:100vh;justify-content:center;align-items:center;font-family:ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';"
page := []byte(fmt.Sprintf(`
<!DOCTYPE html>
<html lang="en">
<head>
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=0" />
<script type="module" defer src="%s"></script>
</head>
<body style="%s">
<span id="feed" data-on-load="%s"></span>
</body>
</html>
`, cdn, style, datastar.GetSSE("/stream")))
mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) {
w.Write(page)
})
mux.HandleFunc("GET /stream", func(w http.ResponseWriter, r *http.Request) {
ticker := time.NewTicker(100 * time.Millisecond)
defer ticker.Stop()
sse := datastar.NewSSE(w, r)
for {
select {
case <-r.Context().Done():
slog.Debug("Client connection closed")
return
case <-ticker.C:
bytes := make([]byte, 3)
if _, err := rand.Read(bytes); err != nil {
slog.Error("Error generating random bytes: ", slog.String("error", err.Error()))
return
}
hexString := hex.EncodeToString(bytes)
frag := fmt.Sprintf(`<span id="feed" style="color:#%s;border:1px solid #%s;border-radius:0.25rem;padding:1rem;">%s</span>`, hexString, hexString, hexString)
sse.MergeFragments(frag)
}
}
})
slog.Info(fmt.Sprintf("Server starting at 0.0.0.0:%d", port))
if err := http.ListenAndServe(fmt.Sprintf("0.0.0.0:%d", port), mux); err != nil {
slog.Error("Error starting server:", slog.String("error", err.Error()))
}
}

133
mvac/datastar.md Normal file
View File

@ -0,0 +1,133 @@
# Understanding Datastar: Backend and Frontend Synergy
You've got it! Understanding how the Go `datastar` package on the server and `datastar.js` in the browser collaborate is key to grasping the "whole picture." Let's break down this synergistic relationship.
## The Big Idea: Server-Driven Interactivity with Client-Side Reactivity
Datastar enables you to build dynamic web UIs where the server often dictates changes, sending HTML fragments or instructions over Server-Sent Events (SSE). The client-side datastar.js intelligently applies these updates, manages a local reactive state (called "signals"), and provides declarative ways to bind this state and behavior to your HTML.
## Step-by-Step Flow
Here's a step-by-step flow and explanation of how they work together:
### 1. Initial Page Load & Client-Side Setup (`datastar.js`)
* **HTML Served**: Your Go application serves an initial HTML page.
* **`datastar.js` Execution**: The browser loads and executes `datastar.js`.
* **DOM Scan & Initialization** (`rt`, `Ie`, `dr` in `datastar.js`):
* `datastar.js` scans the DOM for special attributes (e.g., `data-datastar-show`, `data-datastar-on-click`, `data-datastar-bind`, `data-datastar-signals`). The prefix (`datastar-`) can be aliased using `Kt` (e.g., to just `data-`).
* For each recognized attribute, it initializes the corresponding client-side plugin:
* `data-signals`: Populates the client-side reactive "signal" store with initial values. Signals are like JavaScript variables, but when they change, parts of the UI that depend on them can automatically update.
* `data-computed`: Defines signals whose values are calculated from other signals.
* `data-show`, `data-text`, `data-attr`, `data-class`: These attributes take JavaScript-like expressions. `datastar.js` sets up "effects" that watch the signals mentioned in these expressions. When a relevant signal changes, the expression is re-evaluated, and the DOM is updated accordingly (e.g., an element is shown/hidden, its text content changes).
* `data-bind`: Creates two-way data binding between form input elements and signals. Changes in the input update the signal, and changes to the signal update the input's value.
* `data-on-{event}` (e.g., `data-on-click`): Attaches event listeners. The attribute's value is an expression that gets executed when the event fires. This expression can call "actions."
* `data-ref`: Stores a reference to the DOM element itself in a signal.
* **MutationObserver**: `datastar.js` sets up a `MutationObserver` to automatically initialize any new HTML content that gets added to the page later (e.g., through Datastar's own fragment merging).
### 2. Client Interaction & Request to Server (`datastar.js` actions)
* **User Action**: A user interacts with an element, for example, clicks a button:
html
<button data-datastar-on-click="@post('/api/submit-data', { contentType: 'form' })">Submit</button>
Action Triggered: The data-on-click directive executes its expression.
@post Action (cn plugin in datastar.js):
The @post(...) syntax calls a registered "action" plugin. datastar.js has built-in actions for HTTP methods (@get, @post, @put, @patch, @delete).
This action initiates an HTTP request to the specified URL (/api/submit-data).
Sending Data to Server (B function in datastar.js):
Headers: A Datastar-Request: true header is added to signal to the server that this is a Datastar-initiated request. The Accept header indicates preference for text/event-stream among others.
Payload:
If contentType: 'json' (default for POST/PUT/PATCH), datastar.js can automatically collect the current values of its signals, JSON-stringify them, and send them in the request body (unless excludeSignals: true). The Go server can then use datastar.ReadSignals() to parse this.
If contentType: 'form', it finds the closest <form> (or one specified by a selector option), gathers its FormData, and sends that.
For @get requests, signals (if included) are typically sent as a datastar query parameter.
3. Server-Side Processing (Go datastar package)
Request Handling: Your Go HTTP handler (e.g., in main.go or routes.go) receives the request.
Reading Client Data:
If the client sent signal data, the server can use datastar.ReadSignals(r, &myStruct) to unmarshal it into a Go struct.
Business Logic: The server performs its logic (database queries, calculations, etc.).
Preparing SSE Stream (datastar.NewSSE):
To send updates back, the handler calls sse := datastar.NewSSE(w, r, ...options).
This function:
Sets HTTP headers appropriate for SSE (Content-Type: text/event-stream, Cache-Control: no-cache, Connection: keep-alive).
Can apply compression (e.g., gzip, brotli) to the SSE stream if configured with options like datastar.WithCompression(datastar.WithBrotli()).
Returns a ServerSentEventGenerator (the sse object).
Sending Updates via SSE (sse.Send and sugar methods): The server now uses the sse object to send various types of events to the client:
sse.MergeFragments(htmlString, opts...):
Sends an HTML snippet to the client.
opts can include:
datastar.WithSelector("#target-id") or datastar.WithSelectorf(".css-class-%s", "foo"): Specifies where to merge the HTML. If omitted, Datastar expects the fragment itself to have an ID and will target an element with the same ID.
datastar.WithMergeMode(datastar.FragmentMergeModeInner): How to merge (e.g., morph (default, uses idiomorph-like diffing), inner (innerHTML), outer (replace element), append, prepend, etc.).
datastar.WithViewTransitions(true): To use the browser's View Transition API for smoother updates.
This sends an SSE event with event: datastar-merge-fragments and data lines for the selector, merge mode, and the HTML fragments.
sse.MergeSignals(jsonBytes, opts...) or sse.MarshalAndMergeSignals(goStruct, opts...):
Sends JSON data to update the client's signal store.
opts can include datastar.WithOnlyIfMissing(true).
Sends an SSE event with event: datastar-merge-signals and data lines for the signals JSON.
sse.ExecuteScript(scriptContent, opts...):
Sends JavaScript code for the client to execute.
opts can include datastar.WithExecuteScriptAutoRemove(false) (to keep the script tag) or datastar.WithExecuteScriptAttributes("type module", "defer").
Sends an SSE event with event: datastar-execute-script and data lines for the script, attributes, and auto-remove flag.
Convenience methods like sse.ConsoleLog("message"), sse.Redirect("/new-page"), sse.DispatchCustomEvent("my-event", detail) are built on top of ExecuteScript.
sse.RemoveFragments(selector, opts...):
Instructs the client to remove DOM elements matching the CSS selector.
Sends an SSE event with event: datastar-remove-fragments.
sse.RemoveSignals(path1, path2, ...):
Instructs the client to remove specified signals from its store.
Sends an SSE event with event: datastar-remove-signals.
Templating Integration: Methods like sse.MergeFragmentTempl(myTemplComponent) allow rendering Go templates (like Templ) directly into an HTML fragment string before sending.
4. Client-Side Reception & DOM/State Update (datastar.js watchers)
SSE Connection (Er in datastar.js): datastar.js maintains the SSE connection established by an action (like @post).
Event Parsing (hr, vr in datastar.js): When an SSE message arrives:
It's parsed into its components (event type, data lines, id, retry).
An internal Datastar event is dispatched (e.g., ne(ge, s, F) which corresponds to datastar-merge-fragments).
Watcher Plugins Handle Events: Specific "watcher" plugins in datastar.js listen for these internal events:
datastar-merge-fragments (handled by An plugin):
Parses the selector, mergeMode, fragments, and useViewTransition from the event data.
Locates the target DOM element(s).
If useViewTransition is enabled, it wraps the DOM update in document.startViewTransition(...).
Applies the fragment using the specified mergeMode:
morph (xr function): This is the most sophisticated mode. It uses an algorithm similar to idiomorph to diff the existing DOM with the new fragment and apply only the necessary changes. This is efficient and helps preserve things like input focus and scroll position. It respects data-datastar-ignore-morph attributes.
Others (inner, outer, append, etc.): Perform straightforward DOM manipulations.
Crucially, after merging, it re-scans the modified/new elements (ce(o, Ie)) to initialize any Datastar attributes within them, ensuring new content is also reactive.
datastar-merge-signals (handled by wn plugin):
Parses the JSON payload of signals.
Updates the client-side signal store (ke function). This is batched (Pe function) to optimize updates.
datastar-execute-script (handled by fn plugin):
Creates a <script> tag, populates it with the received script content and attributes, appends it to the <head>, and (by default) removes it after execution.
datastar-remove-fragments (handled by Dn plugin):
Removes DOM elements matching the provided selector.
datastar-remove-signals (handled by Cn plugin):
Removes the specified signals from the client-side store.
5. Client-Side Reactivity (Automatic UI Updates)
Signal Changes: When a signal's value changes (either due to a datastar-merge-signals event from the server, or a data-bind update from user input, or direct manipulation via @setAll or @toggleAll actions):
The signal system in datastar.js identifies all "effects" and "computed signals" that depend on the changed signal.
Re-evaluation:
These dependent expressions (from data-show, data-text, data-bind, etc.) are re-evaluated.
If the result of an expression changes, the corresponding DOM update is performed automatically by datastar.js. For example:
If $isLoading (a signal) changes from true to false, an element with data-datastar-show="!$isLoading" will become visible.
If $user.name changes, an element with data-datastar-text="$user.name" will have its text content updated.
The Cycle Continues
This creates a reactive loop: User Interaction -> Client sends request (with optional state) -> Server processes -> Server sends SSE updates (HTML, data, scripts) -> Client receives, updates DOM & local state -> Client-side reactivity further updates UI based on state changes.
In Summary:
Go Backend (datastar package):
Handles HTTP requests.
Manages business logic.
Can read client-side state sent with requests.
Pushes fine-grained UI updates (HTML fragments), data updates (signals), or script executions to the client via Server-Sent Events.
Provides helpers for common tasks like templating, compression, and generating client-side action attributes.
JavaScript Frontend (datastar.js):
Initializes declarative behaviors from HTML attributes.
Manages a reactive client-side data store ("signals").
Makes requests to the server, potentially including current signal state.
Listens for SSE events from the server.
Intelligently merges HTML fragments into the DOM (with morphing as a default).
Updates its signal store based on server messages.
Executes scripts sent by the server.
Automatically updates the UI when signals change, based on declarative bindings in the HTML.
This architecture aims to keep much of the rendering logic on the server (sending HTML fragments) while still providing a responsive and interactive experience on the client, with a reactive data layer managed by datastar.js.

115
mvac/datastar/consts.go Normal file
View File

@ -0,0 +1,115 @@
// This is auto-generated by Datastar. DO NOT EDIT.
package datastar
import "time"
const (
DatastarKey = "datastar"
Version = "1.0.0-beta.11"
VersionClientByteSize = 40026
VersionClientByteSizeGzip = 14900
//region Default durations
// The default duration for retrying SSE on connection reset. This is part of the underlying retry mechanism of SSE.
DefaultSseRetryDuration = 1000 * time.Millisecond
//endregion Default durations
//region Default strings
// The default attributes for <script/> element use when executing scripts. It is a set of key-value pairs delimited by a newline \\n character.
DefaultExecuteScriptAttributes = "type module"
//endregion Default strings
//region Dataline literals
SelectorDatalineLiteral = "selector "
MergeModeDatalineLiteral = "mergeMode "
FragmentsDatalineLiteral = "fragments "
UseViewTransitionDatalineLiteral = "useViewTransition "
SignalsDatalineLiteral = "signals "
OnlyIfMissingDatalineLiteral = "onlyIfMissing "
PathsDatalineLiteral = "paths "
ScriptDatalineLiteral = "script "
AttributesDatalineLiteral = "attributes "
AutoRemoveDatalineLiteral = "autoRemove "
//endregion Dataline literals
)
var (
//region Default booleans
// Should fragments be merged using the ViewTransition API?
DefaultFragmentsUseViewTransitions = false
// Should a given set of signals merge if they are missing?
DefaultMergeSignalsOnlyIfMissing = false
// Should script element remove itself after execution?
DefaultExecuteScriptAutoRemove = true
//endregion Default booleans
)
//region Enums
//region The mode in which a fragment is merged into the DOM.
type FragmentMergeMode string
const (
// Default value for FragmentMergeMode
// Morphs the fragment into the existing element using idiomorph.
DefaultFragmentMergeMode = FragmentMergeModeMorph
// Morphs the fragment into the existing element using idiomorph.
FragmentMergeModeMorph FragmentMergeMode = "morph"
// Replaces the inner HTML of the existing element.
FragmentMergeModeInner FragmentMergeMode = "inner"
// Replaces the outer HTML of the existing element.
FragmentMergeModeOuter FragmentMergeMode = "outer"
// Prepends the fragment to the existing element.
FragmentMergeModePrepend FragmentMergeMode = "prepend"
// Appends the fragment to the existing element.
FragmentMergeModeAppend FragmentMergeMode = "append"
// Inserts the fragment before the existing element.
FragmentMergeModeBefore FragmentMergeMode = "before"
// Inserts the fragment after the existing element.
FragmentMergeModeAfter FragmentMergeMode = "after"
// Upserts the attributes of the existing element.
FragmentMergeModeUpsertAttributes FragmentMergeMode = "upsertAttributes"
)
//endregion FragmentMergeMode
//region The type protocol on top of SSE which allows for core pushed based communication between the server and the client.
type EventType string
const (
// An event for merging HTML fragments into the DOM.
EventTypeMergeFragments EventType = "datastar-merge-fragments"
// An event for merging signals.
EventTypeMergeSignals EventType = "datastar-merge-signals"
// An event for removing HTML fragments from the DOM.
EventTypeRemoveFragments EventType = "datastar-remove-fragments"
// An event for removing signals.
EventTypeRemoveSignals EventType = "datastar-remove-signals"
// An event for executing <script/> elements in the browser.
EventTypeExecuteScript EventType = "datastar-execute-script"
)
//endregion EventType
//endregion Enums

View File

@ -0,0 +1,234 @@
package datastar
import (
"encoding/json"
"fmt"
"net/http"
"net/url"
"strings"
"time"
)
// ConsoleLog is a convenience method for [see.ExecuteScript].
// It is equivalent to calling [see.ExecuteScript] with [see.WithScript] option set to `console.log(msg)`.
func (sse *ServerSentEventGenerator) ConsoleLog(msg string, opts ...ExecuteScriptOption) error {
call := fmt.Sprintf("console.log(%q)", msg)
return sse.ExecuteScript(call, opts...)
}
// ConsoleLogf is a convenience method for [see.ExecuteScript].
// It is equivalent to calling [see.ExecuteScript] with [see.WithScript] option set to `console.log(fmt.Sprintf(format, args...))`.
func (sse *ServerSentEventGenerator) ConsoleLogf(format string, args ...any) error {
return sse.ConsoleLog(fmt.Sprintf(format, args...))
}
// ConsoleError is a convenience method for [see.ExecuteScript].
// It is equivalent to calling [see.ExecuteScript] with [see.WithScript] option set to `console.error(msg)`.
func (sse *ServerSentEventGenerator) ConsoleError(err error, opts ...ExecuteScriptOption) error {
call := fmt.Sprintf("console.error(%q)", err.Error())
return sse.ExecuteScript(call, opts...)
}
// Redirectf is a convenience method for [see.ExecuteScript].
// It sends a redirect event to the client formatted using [fmt.Sprintf].
func (sse *ServerSentEventGenerator) Redirectf(format string, args ...any) error {
url := fmt.Sprintf(format, args...)
return sse.Redirect(url)
}
// Redirect is a convenience method for [see.ExecuteScript].
// It sends a redirect event to the client .
func (sse *ServerSentEventGenerator) Redirect(url string, opts ...ExecuteScriptOption) error {
js := fmt.Sprintf("setTimeout(() => window.location.href = %q)", url)
return sse.ExecuteScript(js, opts...)
}
// dispatchCustomEventOptions holds the configuration data
// modified by [DispatchCustomEventOption]s
// for dispatching custom events to the client.
type dispatchCustomEventOptions struct {
EventID string
RetryDuration time.Duration
Selector string
Bubbles bool
Cancelable bool
Composed bool
}
// DispatchCustomEventOption configures one custom
// server-sent event.
type DispatchCustomEventOption func(*dispatchCustomEventOptions)
// WithDispatchCustomEventEventID configures an optional event ID for the custom event.
// The client message field [lastEventId] will be set to this value.
// If the next event does not have an event ID, the last used event ID will remain.
//
// [lastEventId]: https://developer.mozilla.org/en-US/docs/Web/API/MessageEvent/lastEventId
func WithDispatchCustomEventEventID(id string) DispatchCustomEventOption {
return func(o *dispatchCustomEventOptions) {
o.EventID = id
}
}
// WithDispatchCustomEventRetryDuration overrides the [DefaultSseRetryDuration] for one custom event.
func WithDispatchCustomEventRetryDuration(retryDuration time.Duration) DispatchCustomEventOption {
return func(o *dispatchCustomEventOptions) {
o.RetryDuration = retryDuration
}
}
// WithDispatchCustomEventSelector replaces the default custom event target `document` with a
// [CSS selector]. If the selector matches multiple HTML elements, the event will be dispatched
// from each one. For example, if the selector is `#my-element`, the event will be dispatched
// from the element with the ID `my-element`. If the selector is `main > section`, the event will be dispatched
// from each `<section>` element which is a direct child of the `<main>` element.
//
// [CSS selector]: https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_selectors
func WithDispatchCustomEventSelector(selector string) DispatchCustomEventOption {
return func(o *dispatchCustomEventOptions) {
o.Selector = selector
}
}
// WithDispatchCustomEventBubbles overrides the default custom [event bubbling] `true` value.
// Setting bubbling to `false` is equivalent to calling `event.stopPropagation()` Javascript
// command on the client side for the dispatched event. This prevents the event from triggering
// event handlers of its parent elements.
//
// [event bubbling]: https://developer.mozilla.org/en-US/docs/Learn_web_development/Core/Scripting/Event_bubbling
func WithDispatchCustomEventBubbles(bubbles bool) DispatchCustomEventOption {
return func(o *dispatchCustomEventOptions) {
o.Bubbles = bubbles
}
}
// WithDispatchCustomEventCancelable overrides the default custom [event cancelability] `true` value.
// Setting cancelability to `false` is blocks `event.preventDefault()` Javascript
// command on the client side for the dispatched event.
//
// [event cancelability]: https://developer.mozilla.org/en-US/docs/Web/API/Event/cancelable
func WithDispatchCustomEventCancelable(cancelable bool) DispatchCustomEventOption {
return func(o *dispatchCustomEventOptions) {
o.Cancelable = cancelable
}
}
// WithDispatchCustomEventComposed overrides the default custom [event composed] `true` value.
// It indicates whether or not the event will propagate across the shadow HTML DOM boundary into
// the document DOM tree. When `false`, the shadow root will be the last node to be offered the event.
//
// [event composed]: https://developer.mozilla.org/en-US/docs/Web/API/Event/composed
func WithDispatchCustomEventComposed(composed bool) DispatchCustomEventOption {
return func(o *dispatchCustomEventOptions) {
o.Composed = composed
}
}
// DispatchCustomEvent is a convenience method for dispatching a custom event by executing
// a client side script via [sse.ExecuteScript] call. The detail struct is marshaled to JSON and
// passed as a parameter to the event.
func (sse *ServerSentEventGenerator) DispatchCustomEvent(eventName string, detail any, opts ...DispatchCustomEventOption) error {
if eventName == "" {
return fmt.Errorf("eventName is required")
}
detailsJSON, err := json.Marshal(detail)
if err != nil {
return fmt.Errorf("failed to marshal detail: %w", err)
}
const defaultSelector = "document"
options := dispatchCustomEventOptions{
EventID: "",
RetryDuration: DefaultSseRetryDuration,
Selector: defaultSelector,
Bubbles: true,
Cancelable: true,
Composed: true,
}
for _, opt := range opts {
opt(&options)
}
elementsJS := `[document]`
if options.Selector != "" && options.Selector != defaultSelector {
elementsJS = fmt.Sprintf(`document.querySelectorAll(%q)`, options.Selector)
}
js := fmt.Sprintf(`
const elements = %s
const event = new CustomEvent(%q, {
bubbles: %t,
cancelable: %t,
composed: %t,
detail: %s,
});
elements.forEach((element) => {
element.dispatchEvent(event);
});
`,
elementsJS,
eventName,
options.Bubbles,
options.Cancelable,
options.Composed,
string(detailsJSON),
)
executeOptions := make([]ExecuteScriptOption, 0)
if options.EventID != "" {
executeOptions = append(executeOptions, WithExecuteScriptEventID(options.EventID))
}
if options.RetryDuration != 0 {
executeOptions = append(executeOptions, WithExecuteScriptRetryDuration(options.RetryDuration))
}
return sse.ExecuteScript(js, executeOptions...)
}
// ReplaceURL replaces the current URL in the browser's history.
func (sse *ServerSentEventGenerator) ReplaceURL(u url.URL, opts ...ExecuteScriptOption) error {
js := fmt.Sprintf(`window.history.replaceState({}, "", %q)`, u.String())
return sse.ExecuteScript(js, opts...)
}
// ReplaceURLQuerystring is a convenience wrapper for [sse.ReplaceURL] that replaces the query
// string of the current URL request with new a new query built from the provided values.
func (sse *ServerSentEventGenerator) ReplaceURLQuerystring(r *http.Request, values url.Values, opts ...ExecuteScriptOption) error {
// TODO: rename this function to ReplaceURLQuery
u := *r.URL
u.RawQuery = values.Encode()
return sse.ReplaceURL(u, opts...)
}
// Prefetch is a convenience wrapper for [sse.ExecuteScript] that prefetches the provided links.
// It follows the Javascript [speculation rules API] prefetch specification.
//
// [speculation rules API]: https://developer.mozilla.org/en-US/docs/Web/API/Speculation_Rules_API
func (sse *ServerSentEventGenerator) Prefetch(urls ...string) error {
wrappedURLs := make([]string, len(urls))
for i, url := range urls {
wrappedURLs[i] = fmt.Sprintf(`"%s"`, url)
}
script := fmt.Sprintf(`
{
"prefetch": [
{
"source": "list",
"urls": [
%s
]
}
]
}
`, strings.Join(wrappedURLs, ",\n\t\t\t\t"))
return sse.ExecuteScript(
script,
WithExecuteScriptAutoRemove(false),
WithExecuteScriptAttributes("type speculationrules"),
)
}

112
mvac/datastar/execute.go Normal file
View File

@ -0,0 +1,112 @@
package datastar
import (
"fmt"
"strconv"
"strings"
"time"
)
// executeScriptOptions hold script options that will be translated to [SSEEventOptions].
type executeScriptOptions struct {
EventID string
RetryDuration time.Duration
Attributes []string
AutoRemove *bool
}
// ExecuteScriptOption configures script execution event that will be sent to the client.
type ExecuteScriptOption func(*executeScriptOptions)
// WithExecuteScriptEventID configures an optional event ID for the script execution event.
// The client message field [lastEventId] will be set to this value.
// If the next event does not have an event ID, the last used event ID will remain.
//
// [lastEventId]: https://developer.mozilla.org/en-US/docs/Web/API/MessageEvent/lastEventId
func WithExecuteScriptEventID(id string) ExecuteScriptOption {
return func(o *executeScriptOptions) {
o.EventID = id
}
}
// WithExecuteScriptRetryDuration overrides the [DefaultSseRetryDuration] for this script
// execution only.
func WithExecuteScriptRetryDuration(retryDuration time.Duration) ExecuteScriptOption {
return func(o *executeScriptOptions) {
o.RetryDuration = retryDuration
}
}
// WithExecuteScriptAttributes overrides the default script attribute
// value `type module`, which renders as `<script type="module">` in client's browser.
// Each attribute should include a pair of plain words representing the attribute name and value
// without any formatting.
func WithExecuteScriptAttributes(attributes ...string) ExecuteScriptOption {
return func(o *executeScriptOptions) {
o.Attributes = attributes
}
}
// WithExecuteScriptAttributeKVs is an alternative option for [WithExecuteScriptAttributes].
// Even parameters are keys, odd parameters are their values.
func WithExecuteScriptAttributeKVs(kvs ...string) ExecuteScriptOption {
if len(kvs)%2 != 0 {
panic("WithExecuteScriptAttributeKVs requires an even number of arguments")
}
attributes := make([]string, 0, len(kvs)/2)
for i := 0; i < len(kvs); i += 2 {
attribute := fmt.Sprintf("%s %s", kvs[i], kvs[i+1])
attributes = append(attributes, attribute)
}
return WithExecuteScriptAttributes(attributes...)
}
// WithExecuteScriptAutoRemove requires the client to eliminate the script element after its execution.
func WithExecuteScriptAutoRemove(autoremove bool) ExecuteScriptOption {
return func(o *executeScriptOptions) {
o.AutoRemove = &autoremove
}
}
// ExecuteScript runs a script in the client browser. Seperate commands with semicolons.
func (sse *ServerSentEventGenerator) ExecuteScript(scriptContents string, opts ...ExecuteScriptOption) error {
options := &executeScriptOptions{
RetryDuration: DefaultSseRetryDuration,
Attributes: []string{"type module"},
}
for _, opt := range opts {
opt(options)
}
sendOpts := make([]SSEEventOption, 0, 2)
if options.EventID != "" {
sendOpts = append(sendOpts, WithSSEEventId(options.EventID))
}
if options.RetryDuration != DefaultSseRetryDuration {
sendOpts = append(sendOpts, WithSSERetryDuration(options.RetryDuration))
}
dataLines := make([]string, 0, 64)
if options.AutoRemove != nil && *options.AutoRemove != DefaultExecuteScriptAutoRemove {
dataLines = append(dataLines, AutoRemoveDatalineLiteral+strconv.FormatBool(*options.AutoRemove))
}
dataLinesJoined := strings.Join(dataLines, NewLine)
if dataLinesJoined != DefaultExecuteScriptAttributes {
for _, attribute := range options.Attributes {
dataLines = append(dataLines, AttributesDatalineLiteral+attribute)
}
}
scriptLines := strings.Split(scriptContents, NewLine)
for _, line := range scriptLines {
dataLines = append(dataLines, ScriptDatalineLiteral+line)
}
return sse.Send(
EventTypeExecuteScript,
dataLines,
sendOpts...,
)
}

View File

@ -0,0 +1,190 @@
package datastar
import (
"context"
"fmt"
"io"
"github.com/valyala/bytebufferpool"
)
// ValidFragmentMergeTypes is a list of valid fragment merge modes.
var ValidFragmentMergeTypes = []FragmentMergeMode{
FragmentMergeModeMorph,
FragmentMergeModeInner,
FragmentMergeModeOuter,
FragmentMergeModePrepend,
FragmentMergeModeAppend,
FragmentMergeModeBefore,
FragmentMergeModeAfter,
FragmentMergeModeUpsertAttributes,
}
// FragmentMergeTypeFromString converts a string to a [FragmentMergeMode].
func FragmentMergeTypeFromString(s string) (FragmentMergeMode, error) {
switch s {
case "morph":
return FragmentMergeModeMorph, nil
case "inner":
return FragmentMergeModeInner, nil
case "outer":
return FragmentMergeModeOuter, nil
case "prepend":
return FragmentMergeModePrepend, nil
case "append":
return FragmentMergeModeAppend, nil
case "before":
return FragmentMergeModeBefore, nil
case "after":
return FragmentMergeModeAfter, nil
case "upsertAttributes":
return FragmentMergeModeUpsertAttributes, nil
default:
return "", fmt.Errorf("invalid fragment merge type: %s", s)
}
}
// WithMergeMorph creates a MergeFragmentOption that merges fragments using the morph mode.
func WithMergeMorph() MergeFragmentOption {
return WithMergeMode(FragmentMergeModeMorph)
}
// WithMergeInner creates a MergeFragmentOption that merges fragments using the inner mode.
func WithMergeInner() MergeFragmentOption {
return WithMergeMode(FragmentMergeModeInner)
}
// WithMergeOuter creates a MergeFragmentOption that merges fragments using the outer mode.
func WithMergeOuter() MergeFragmentOption {
return WithMergeMode(FragmentMergeModeOuter)
}
// WithMergePrepend creates a MergeFragmentOption that merges fragments using the prepend mode.
func WithMergePrepend() MergeFragmentOption {
return WithMergeMode(FragmentMergeModePrepend)
}
// WithMergeAppend creates a MergeFragmentOption that merges fragments using the append mode.
func WithMergeAppend() MergeFragmentOption {
return WithMergeMode(FragmentMergeModeAppend)
}
// WithMergeBefore creates a MergeFragmentOption that merges fragments using the before mode.
func WithMergeBefore() MergeFragmentOption {
return WithMergeMode(FragmentMergeModeBefore)
}
// WithMergeAfter creates a MergeFragmentOption that merges fragments using the after mode.
func WithMergeAfter() MergeFragmentOption {
return WithMergeMode(FragmentMergeModeAfter)
}
// WithMergeUpsertAttributes creates a MergeFragmentOption that merges fragments using the upsert attributes mode.
func WithMergeUpsertAttributes() MergeFragmentOption {
return WithMergeMode(FragmentMergeModeUpsertAttributes)
}
// WithSelectorID is a convenience wrapper for [WithSelector] option
// equivalent to calling `WithSelector("#"+id)`.
func WithSelectorID(id string) MergeFragmentOption {
return WithSelector("#" + id)
}
// WithViewTransitions enables the use of view transitions when merging fragments.
func WithViewTransitions() MergeFragmentOption {
return func(o *mergeFragmentOptions) {
o.UseViewTransitions = true
}
}
// WithoutViewTransitions disables the use of view transitions when merging fragments.
func WithoutViewTransitions() MergeFragmentOption {
return func(o *mergeFragmentOptions) {
o.UseViewTransitions = false
}
}
// MergeFragmentf is a convenience wrapper for [MergeFragments] option
// equivalent to calling `MergeFragments(fmt.Sprintf(format, args...))`.
func (sse *ServerSentEventGenerator) MergeFragmentf(format string, args ...any) error {
return sse.MergeFragments(fmt.Sprintf(format, args...))
}
// TemplComponent satisfies the component rendering interface for HTML template engine [Templ].
// This separate type ensures compatibility with [Templ] without imposing a dependency requirement
// on those who prefer to use a different template engine.
//
// [Templ]: https://templ.guide/
type TemplComponent interface {
Render(ctx context.Context, w io.Writer) error
}
// MergeFragmentTempl is a convenience adaptor of [sse.MergeFragments] for [TemplComponent].
func (sse *ServerSentEventGenerator) MergeFragmentTempl(c TemplComponent, opts ...MergeFragmentOption) error {
buf := bytebufferpool.Get()
defer bytebufferpool.Put(buf)
if err := c.Render(sse.Context(), buf); err != nil {
return fmt.Errorf("failed to merge fragment: %w", err)
}
if err := sse.MergeFragments(buf.String(), opts...); err != nil {
return fmt.Errorf("failed to merge fragment: %w", err)
}
return nil
}
// GoStarElementRenderer satisfies the component rendering interface for HTML template engine [GoStar].
// This separate type ensures compatibility with [GoStar] without imposing a dependency requirement
// on those who prefer to use a different template engine.
//
// [GoStar]: https://github.com/delaneyj/gostar
type GoStarElementRenderer interface {
Render(w io.Writer) error
}
// MergeFragmentGostar is a convenience adaptor of [sse.MergeFragments] for [GoStarElementRenderer].
func (sse *ServerSentEventGenerator) MergeFragmentGostar(child GoStarElementRenderer, opts ...MergeFragmentOption) error {
buf := bytebufferpool.Get()
defer bytebufferpool.Put(buf)
if err := child.Render(buf); err != nil {
return fmt.Errorf("failed to render: %w", err)
}
if err := sse.MergeFragments(buf.String(), opts...); err != nil {
return fmt.Errorf("failed to merge fragment: %w", err)
}
return nil
}
// GetSSE is a convenience method for generating Datastar backend [get] action attribute.
//
// [get]: https://data-star.dev/reference/action_plugins#get
func GetSSE(urlFormat string, args ...any) string {
return fmt.Sprintf(`@get('%s')`, fmt.Sprintf(urlFormat, args...))
}
// PostSSE is a convenience method for generating Datastar backend [post] action attribute.
//
// [post]: https://data-star.dev/reference/action_plugins#post
func PostSSE(urlFormat string, args ...any) string {
return fmt.Sprintf(`@post('%s')`, fmt.Sprintf(urlFormat, args...))
}
// PutSSE is a convenience method for generating Datastar backend [put] action attribute.
//
// [put]: https://data-star.dev/reference/action_plugins#put
func PutSSE(urlFormat string, args ...any) string {
return fmt.Sprintf(`@put('%s')`, fmt.Sprintf(urlFormat, args...))
}
// PatchSSE is a convenience method for generating Datastar backend [patch] action attribute.
//
// [patch]: https://data-star.dev/reference/action_plugins#patch
func PatchSSE(urlFormat string, args ...any) string {
return fmt.Sprintf(`@patch('%s')`, fmt.Sprintf(urlFormat, args...))
}
// DeleteSSE is a convenience method for generating Datastar backend [delete] action attribute.
//
// [delete]: https://data-star.dev/reference/action_plugins#delete
func DeleteSSE(urlFormat string, args ...any) string {
return fmt.Sprintf(`@delete('%s')`, fmt.Sprintf(urlFormat, args...))
}

190
mvac/datastar/fragments.go Normal file
View File

@ -0,0 +1,190 @@
package datastar
import (
"fmt"
"strconv"
"strings"
"time"
)
// mergeFragmentOptions holds the configuration data for [MergeFragmentOption]s used
// for initialization of [sse.MergeFragments] event.
type mergeFragmentOptions struct {
EventID string
RetryDuration time.Duration
Selector string
MergeMode FragmentMergeMode
UseViewTransitions bool
}
// MergeFragmentOption configures the [sse.MergeFragments] event initialization.
type MergeFragmentOption func(*mergeFragmentOptions)
// WithMergeFragmentsEventID configures an optional event ID for the fragments merge event.
// The client message field [lastEventId] will be set to this value.
// If the next event does not have an event ID, the last used event ID will remain.
//
// [lastEventId]: https://developer.mozilla.org/en-US/docs/Web/API/MessageEvent/lastEventId
func WithMergeFragmentsEventID(id string) MergeFragmentOption {
return func(o *mergeFragmentOptions) {
o.EventID = id
}
}
// WithSelectorf is a convenience wrapper for [WithSelector] option that formats the selector string
// using the provided format and arguments similar to [fmt.Sprintf].
func WithSelectorf(selectorFormat string, args ...any) MergeFragmentOption {
selector := fmt.Sprintf(selectorFormat, args...)
return WithSelector(selector)
}
// WithSelector specifies the [CSS selector] for HTML elements that a fragment will be merged over or
// merged next to, depending on the merge mode.
//
// [CSS selector]: https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Selectors
func WithSelector(selector string) MergeFragmentOption {
return func(o *mergeFragmentOptions) {
o.Selector = selector
}
}
// WithMergeMode overrides the [DefaultFragmentMergeMode] for the fragment.
// Choose a valid [FragmentMergeMode].
func WithMergeMode(merge FragmentMergeMode) MergeFragmentOption {
return func(o *mergeFragmentOptions) {
o.MergeMode = merge
}
}
// WithUseViewTransitions specifies whether to use [view transitions] when merging fragments.
//
// [view transitions]: https://developer.mozilla.org/en-US/docs/Web/API/View_Transition_API
func WithUseViewTransitions(useViewTransition bool) MergeFragmentOption {
return func(o *mergeFragmentOptions) {
o.UseViewTransitions = useViewTransition
}
}
// MergeFragments sends an HTML fragment to the client to update the DOM tree with.
func (sse *ServerSentEventGenerator) MergeFragments(fragment string, opts ...MergeFragmentOption) error {
options := &mergeFragmentOptions{
EventID: "",
RetryDuration: DefaultSseRetryDuration,
Selector: "",
MergeMode: FragmentMergeModeMorph,
}
for _, opt := range opts {
opt(options)
}
sendOptions := make([]SSEEventOption, 0, 2)
if options.EventID != "" {
sendOptions = append(sendOptions, WithSSEEventId(options.EventID))
}
if options.RetryDuration > 0 {
sendOptions = append(sendOptions, WithSSERetryDuration(options.RetryDuration))
}
dataRows := make([]string, 0, 4)
if options.Selector != "" {
dataRows = append(dataRows, SelectorDatalineLiteral+options.Selector)
}
if options.MergeMode != FragmentMergeModeMorph {
dataRows = append(dataRows, MergeModeDatalineLiteral+string(options.MergeMode))
}
if options.UseViewTransitions {
dataRows = append(dataRows, UseViewTransitionDatalineLiteral+"true")
}
if fragment != "" {
parts := strings.Split(fragment, "\n")
for _, part := range parts {
dataRows = append(dataRows, FragmentsDatalineLiteral+part)
}
}
if err := sse.Send(
EventTypeMergeFragments,
dataRows,
sendOptions...,
); err != nil {
return fmt.Errorf("failed to send fragment: %w", err)
}
return nil
}
// mergeFragmentOptions holds the configuration data for [RemoveFragmentsOption]s used
// for initialization of [sse.RemoveFragments] event.
type removeFragmentsOptions struct {
EventID string
RetryDuration time.Duration
UseViewTransitions *bool
}
// RemoveFragmentsOption configures the [sse.RemoveFragments] event.
type RemoveFragmentsOption func(*removeFragmentsOptions)
// WithRemoveEventID configures an optional event ID for the fragment removal event.
// The client message field [lastEventId] will be set to this value.
// If the next event does not have an event ID, the last used event ID will remain.
//
// [lastEventId]: https://developer.mozilla.org/en-US/docs/Web/API/MessageEvent/lastEventId
func WithRemoveEventID(id string) RemoveFragmentsOption {
return func(o *removeFragmentsOptions) {
o.EventID = id
}
}
// WithExecuteScriptRetryDuration overrides the [DefaultSseRetryDuration] for this script
// execution only.
func WithRemoveRetryDuration(d time.Duration) RemoveFragmentsOption {
return func(o *removeFragmentsOptions) {
o.RetryDuration = d
}
}
// WithRemoveUseViewTransitions specifies whether to use [view transitions] when merging fragments.
//
// [view transitions]: https://developer.mozilla.org/en-US/docs/Web/API/View_Transition_API
func WithRemoveUseViewTransitions(useViewTransition bool) RemoveFragmentsOption {
return func(o *removeFragmentsOptions) {
o.UseViewTransitions = &useViewTransition
}
}
// MergeFragments sends a [CSS selector] to the client to update the DOM tree by removing matching elements.
//
// [CSS selector]: https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Selectors
func (sse *ServerSentEventGenerator) RemoveFragments(selector string, opts ...RemoveFragmentsOption) error {
if selector == "" {
panic("missing " + SelectorDatalineLiteral)
}
options := &removeFragmentsOptions{
EventID: "",
RetryDuration: DefaultSseRetryDuration,
UseViewTransitions: nil,
}
for _, opt := range opts {
opt(options)
}
dataRows := []string{SelectorDatalineLiteral + selector}
if options.UseViewTransitions != nil {
dataRows = append(dataRows, UseViewTransitionDatalineLiteral+strconv.FormatBool(*options.UseViewTransitions))
}
sendOptions := make([]SSEEventOption, 0, 2)
if options.EventID != "" {
sendOptions = append(sendOptions, WithSSEEventId(options.EventID))
}
if options.RetryDuration > 0 {
sendOptions = append(sendOptions, WithSSERetryDuration(options.RetryDuration))
}
if err := sse.Send(EventTypeRemoveFragments, dataRows, sendOptions...); err != nil {
return fmt.Errorf("failed to send remove: %w", err)
}
return nil
}

View File

@ -0,0 +1,20 @@
package datastar
import "testing"
func TestAllValidFragmentMergeTypes(t *testing.T) {
var err error
for _, validType := range ValidFragmentMergeTypes {
if _, err = FragmentMergeTypeFromString(string(validType)); err != nil {
t.Errorf("Expected %v to be a valid fragment merge type, but it was rejected: %v", validType, err)
}
}
if _, err = FragmentMergeTypeFromString(""); err == nil {
t.Errorf("Expected an empty string to be an invalid fragment merge type, but it was accepted")
}
if _, err = FragmentMergeTypeFromString("fakeType"); err == nil {
t.Errorf("Expected a fake type to be an invalid fragment merge type, but it was accepted")
}
}

View File

@ -0,0 +1,42 @@
package datastar
import (
"encoding/json"
"fmt"
)
// MarshalAndMergeSignals is a convenience method for [see.MergeSignals].
// It marshals a given signals struct into JSON and
// emits a [EventTypeMergeSignals] event.
func (sse *ServerSentEventGenerator) MarshalAndMergeSignals(signals any, opts ...MergeSignalsOption) error {
b, err := json.Marshal(signals)
if err != nil {
panic(err)
}
if err := sse.MergeSignals(b, opts...); err != nil {
return fmt.Errorf("failed to merge signals: %w", err)
}
return nil
}
// MarshalAndMergeSignalsIfMissing is a convenience method for [see.MarshalAndMergeSignals].
// It is equivalent to calling [see.MarshalAndMergeSignals] with [see.WithOnlyIfMissing(true)] option.
func (sse *ServerSentEventGenerator) MarshalAndMergeSignalsIfMissing(signals any, opts ...MergeSignalsOption) error {
if err := sse.MarshalAndMergeSignals(
signals,
append(opts, WithOnlyIfMissing(true))...,
); err != nil {
return fmt.Errorf("failed to merge signals if missing: %w", err)
}
return nil
}
// MergeSignalsIfMissingRaw is a convenience method for [see.MergeSignals].
// It is equivalent to calling [see.MergeSignals] with [see.WithOnlyIfMissing(true)] option.
func (sse *ServerSentEventGenerator) MergeSignalsIfMissingRaw(signalsJSON string) error {
if err := sse.MergeSignals([]byte(signalsJSON), WithOnlyIfMissing(true)); err != nil {
return fmt.Errorf("failed to merge signals if missing: %w", err)
}
return nil
}

147
mvac/datastar/signals.go Normal file
View File

@ -0,0 +1,147 @@
package datastar
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"net/http"
"strconv"
"time"
"github.com/valyala/bytebufferpool"
)
var (
// ErrNoPathsProvided is returned when no paths were provided for // for [sse.RemoveSignals] call.
ErrNoPathsProvided = errors.New("no paths provided")
)
// mergeSignalsOptions holds configuration options for merging signals.
type mergeSignalsOptions struct {
EventID string
RetryDuration time.Duration
OnlyIfMissing bool
}
// MergeSignalsOption configures one [EventTypeMergeSignals] event.
type MergeSignalsOption func(*mergeSignalsOptions)
// WithMergeSignalsEventID configures an optional event ID for the signals merge event.
// The client message field [lastEventId] will be set to this value.
// If the next event does not have an event ID, the last used event ID will remain.
//
// [lastEventId]: https://developer.mozilla.org/en-US/docs/Web/API/MessageEvent/lastEventId
func WithMergeSignalsEventID(id string) MergeSignalsOption {
return func(o *mergeSignalsOptions) {
o.EventID = id
}
}
// WithMergeSignalsRetryDuration overrides the [DefaultSseRetryDuration] for signal merging.
func WithMergeSignalsRetryDuration(retryDuration time.Duration) MergeSignalsOption {
return func(o *mergeSignalsOptions) {
o.RetryDuration = retryDuration
}
}
// WithOnlyIfMissing instructs the client to only merge signals if they are missing.
func WithOnlyIfMissing(onlyIfMissing bool) MergeSignalsOption {
return func(o *mergeSignalsOptions) {
o.OnlyIfMissing = onlyIfMissing
}
}
// MergeSignals sends a [EventTypeMergeSignals] to the client.
// Requires a JSON-encoded payload.
func (sse *ServerSentEventGenerator) MergeSignals(signalsContents []byte, opts ...MergeSignalsOption) error {
options := &mergeSignalsOptions{
EventID: "",
RetryDuration: DefaultSseRetryDuration,
OnlyIfMissing: false,
}
for _, opt := range opts {
opt(options)
}
dataRows := make([]string, 0, 32)
if options.OnlyIfMissing {
dataRows = append(dataRows, OnlyIfMissingDatalineLiteral+strconv.FormatBool(options.OnlyIfMissing))
}
lines := bytes.Split(signalsContents, newLineBuf)
for _, line := range lines {
dataRows = append(dataRows, SignalsDatalineLiteral+string(line))
}
sendOptions := make([]SSEEventOption, 0, 2)
if options.EventID != "" {
sendOptions = append(sendOptions, WithSSEEventId(options.EventID))
}
if options.RetryDuration != DefaultSseRetryDuration {
sendOptions = append(sendOptions, WithSSERetryDuration(options.RetryDuration))
}
if err := sse.Send(
EventTypeMergeSignals,
dataRows,
sendOptions...,
); err != nil {
return fmt.Errorf("failed to send merge signals: %w", err)
}
return nil
}
// RemoveSignals sends a [EventTypeRemoveSignals] event to the client.
// Requires a non-empty list of paths.
func (sse *ServerSentEventGenerator) RemoveSignals(paths ...string) error {
if len(paths) == 0 {
return ErrNoPathsProvided
}
dataRows := make([]string, 0, len(paths))
for _, path := range paths {
dataRows = append(dataRows, PathsDatalineLiteral+path)
}
if err := sse.Send(
EventTypeRemoveSignals,
dataRows,
); err != nil {
return fmt.Errorf("failed to send remove signals: %w", err)
}
return nil
}
// ReadSignals extracts Datastar signals from
// an HTTP request and unmarshals them into the signals target,
// which should be a pointer to a struct.
//
// Expects signals in [URL.Query] for [http.MethodGet] requests.
// Expects JSON-encoded signals in [Request.Body] for other request methods.
func ReadSignals(r *http.Request, signals any) error {
var dsInput []byte
if r.Method == "GET" {
dsJSON := r.URL.Query().Get(DatastarKey)
if dsJSON == "" {
return nil
} else {
dsInput = []byte(dsJSON)
}
} else {
buf := bytebufferpool.Get()
defer bytebufferpool.Put(buf)
if _, err := buf.ReadFrom(r.Body); err != nil {
if err == http.ErrBodyReadAfterClose {
return fmt.Errorf("body already closed, are you sure you created the SSE ***AFTER*** the ReadSignals? %w", err)
}
return fmt.Errorf("failed to read body: %w", err)
}
dsInput = buf.Bytes()
}
if err := json.Unmarshal(dsInput, signals); err != nil {
return fmt.Errorf("failed to unmarshal: %w", err)
}
return nil
}

View File

@ -0,0 +1,294 @@
package datastar
import (
"strings"
"github.com/CAFxX/httpcompression/contrib/andybalholm/brotli"
"github.com/CAFxX/httpcompression/contrib/compress/gzip"
"github.com/CAFxX/httpcompression/contrib/compress/zlib"
"github.com/CAFxX/httpcompression/contrib/klauspost/zstd"
zstd_opts "github.com/klauspost/compress/zstd"
"github.com/CAFxX/httpcompression"
)
// CompressionStrategy indicates the strategy for selecting the compression algorithm.
type CompressionStrategy string
const (
// ClientPriority indicates that the client's preferred compression algorithm
// should be used if possible.
ClientPriority CompressionStrategy = "client_priority"
// ServerPriority indicates that the server's preferred compression algorithm
// should be used.
ServerPriority CompressionStrategy = "server_priority"
// Forced indicates that the first provided compression
// algorithm must be used regardless of client or server preferences.
Forced CompressionStrategy = "forced"
)
// Compressor pairs a [httpcompression.CompressorProvider]
// with an encoding HTTP content type.
type Compressor struct {
Encoding string
Compressor httpcompression.CompressorProvider
}
// compressionOptions holds all the data for server-sent events
// message compression configuration initiated by [CompressionOption]s.
type compressionOptions struct {
CompressionStrategy CompressionStrategy
ClientEncodings []string
Compressors []Compressor
}
// CompressionOption configures server-sent events
// message compression.
type CompressionOption func(*compressionOptions)
// GzipOption configures the Gzip compression algorithm.
type GzipOption func(*gzip.Options)
// WithGzipLevel determines the algorithm's compression level.
// Higher values result in smaller output at the cost of higher CPU usage.
//
// Choose one of the following levels:
// - [gzip.NoCompression]
// - [gzip.BestSpeed]
// - [gzip.BestCompression]
// - [gzip.DefaultCompression]
// - [gzip.HuffmanOnly]
func WithGzipLevel(level int) GzipOption {
return func(opts *gzip.Options) {
opts.Level = level
}
}
// WithGzip appends a [Gzip] compressor to the list of compressors.
//
// [Gzip]: https://en.wikipedia.org/wiki/Gzip
func WithGzip(opts ...GzipOption) CompressionOption {
return func(cfg *compressionOptions) {
// set default options
options := gzip.Options{
Level: gzip.DefaultCompression,
}
// Apply all provided options.
for _, opt := range opts {
opt(&options)
}
gzipCompressor, _ := gzip.New(options)
compressor := Compressor{
Encoding: gzip.Encoding,
Compressor: gzipCompressor,
}
cfg.Compressors = append(cfg.Compressors, compressor)
}
}
// DeflateOption configures the Deflate compression algorithm.
type DeflateOption func(*zlib.Options)
// WithDeflateLevel determines the algorithm's compression level.
// Higher values result in smaller output at the cost of higher CPU usage.
//
// Choose one of the following levels:
// - [zlib.NoCompression]
// - [zlib.BestSpeed]
// - [zlib.BestCompression]
// - [zlib.DefaultCompression]
// - [zlib.HuffmanOnly]
func WithDeflateLevel(level int) DeflateOption {
return func(opts *zlib.Options) {
opts.Level = level
}
}
// WithDeflateDictionary sets the dictionary used by the algorithm.
// This can improve compression ratio for repeated data.
func WithDeflateDictionary(dict []byte) DeflateOption {
return func(opts *zlib.Options) {
opts.Dictionary = dict
}
}
// WithDeflate appends a [Deflate] compressor to the list of compressors.
//
// [Deflate]: https://en.wikipedia.org/wiki/Deflate
func WithDeflate(opts ...DeflateOption) CompressionOption {
return func(cfg *compressionOptions) {
options := zlib.Options{
Level: zlib.DefaultCompression,
}
for _, opt := range opts {
opt(&options)
}
zlibCompressor, _ := zlib.New(options)
compressor := Compressor{
Encoding: zlib.Encoding,
Compressor: zlibCompressor,
}
cfg.Compressors = append(cfg.Compressors, compressor)
}
}
// BrotliOption configures the Brotli compression algorithm.
type BrotliOption func(*brotli.Options)
// WithBrotliLevel determines the algorithm's compression level.
// Higher values result in smaller output at the cost of higher CPU usage.
// Fastest compression level is 0. Best compression level is 11.
// Defaults to 6.
func WithBrotliLevel(level int) BrotliOption {
return func(opts *brotli.Options) {
opts.Quality = level
}
}
// WithBrotliLGWin the sliding window size for Brotli compression
// algorithm. Select a value between 10 and 24.
// Defaults to 0, indicating automatic window size selection based on compression quality.
func WithBrotliLGWin(lgwin int) BrotliOption {
return func(opts *brotli.Options) {
opts.LGWin = lgwin
}
}
// WithBrotli appends a [Brotli] compressor to the list of compressors.
//
// [Brotli]: https://en.wikipedia.org/wiki/Brotli
func WithBrotli(opts ...BrotliOption) CompressionOption {
return func(cfg *compressionOptions) {
options := brotli.Options{
Quality: brotli.DefaultCompression,
}
for _, opt := range opts {
opt(&options)
}
brotliCompressor, _ := brotli.New(options)
compressor := Compressor{
Encoding: brotli.Encoding,
Compressor: brotliCompressor,
}
cfg.Compressors = append(cfg.Compressors, compressor)
}
}
// WithZstd appends a [Zstd] compressor to the list of compressors.
//
// [Zstd]: https://en.wikipedia.org/wiki/Zstd
func WithZstd(opts ...zstd_opts.EOption) CompressionOption {
return func(cfg *compressionOptions) {
zstdCompressor, _ := zstd.New(opts...)
compressor := Compressor{
Encoding: zstd.Encoding,
Compressor: zstdCompressor,
}
cfg.Compressors = append(cfg.Compressors, compressor)
}
}
// WithClientPriority sets the compression strategy to [ClientPriority].
// The compression algorithm will be selected based on the
// client's preference from the list of included compressors.
func WithClientPriority() CompressionOption {
return func(cfg *compressionOptions) {
cfg.CompressionStrategy = ClientPriority
}
}
// WithServerPriority sets the compression strategy to [ServerPriority].
// The compression algorithm will be selected based on the
// server's preference from the list of included compressors.
func WithServerPriority() CompressionOption {
return func(cfg *compressionOptions) {
cfg.CompressionStrategy = ServerPriority
}
}
// WithForced sets the compression strategy to [Forced].
// The first compression algorithm will be selected
// from the list of included compressors.
func WithForced() CompressionOption {
return func(cfg *compressionOptions) {
cfg.CompressionStrategy = Forced
}
}
// WithCompression adds compression to server-sent event stream.
func WithCompression(opts ...CompressionOption) SSEOption {
return func(sse *ServerSentEventGenerator) {
cfg := &compressionOptions{
CompressionStrategy: ClientPriority,
ClientEncodings: parseEncodings(sse.acceptEncoding),
}
// apply options
for _, opt := range opts {
opt(cfg)
}
// set defaults
if len(cfg.Compressors) == 0 {
WithBrotli()(cfg)
WithZstd()(cfg)
WithGzip()(cfg)
WithDeflate()(cfg)
}
switch cfg.CompressionStrategy {
case ClientPriority:
for _, clientEnc := range cfg.ClientEncodings {
for _, comp := range cfg.Compressors {
if comp.Encoding == clientEnc {
sse.w = comp.Compressor.Get(sse.w)
sse.encoding = comp.Encoding
return
}
}
}
case ServerPriority:
for _, comp := range cfg.Compressors {
for _, clientEnc := range cfg.ClientEncodings {
if comp.Encoding == clientEnc {
sse.w = comp.Compressor.Get(sse.w)
sse.encoding = comp.Encoding
return
}
}
}
case Forced:
if len(cfg.Compressors) > 0 {
sse.w = cfg.Compressors[0].Compressor.Get(sse.w)
sse.encoding = cfg.Compressors[0].Encoding
}
}
}
}
func parseEncodings(header string) []string {
parts := strings.Split(header, ",")
var tokens []string
for _, part := range parts {
token := strings.SplitN(strings.TrimSpace(part), ";", 2)[0]
if token != "" {
tokens = append(tokens, token)
}
}
return tokens
}

212
mvac/datastar/sse.go Normal file
View File

@ -0,0 +1,212 @@
package datastar
import (
"context"
"errors"
"fmt"
"io"
"net/http"
"strconv"
"sync"
"time"
"github.com/valyala/bytebufferpool"
)
// ServerSentEventGenerator streams events into
// an [http.ResponseWriter]. Each event is flushed immediately.
type ServerSentEventGenerator struct {
ctx context.Context
mu *sync.Mutex
w io.Writer
rc *http.ResponseController
shouldLogPanics bool
encoding string
acceptEncoding string
}
// SSEOption configures the initialization of an
// HTTP Server-Sent Event stream.
type SSEOption func(*ServerSentEventGenerator)
// NewSSE upgrades an [http.ResponseWriter] to an HTTP Server-Sent Event stream.
// The connection is kept alive until the context is canceled or the response is closed by returning from the handler.
// Run an event loop for persistent streaming.
func NewSSE(w http.ResponseWriter, r *http.Request, opts ...SSEOption) *ServerSentEventGenerator {
rc := http.NewResponseController(w)
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Content-Type", "text/event-stream")
if r.ProtoMajor == 1 {
w.Header().Set("Connection", "keep-alive")
}
sseHandler := &ServerSentEventGenerator{
ctx: r.Context(),
mu: &sync.Mutex{},
w: w,
rc: rc,
shouldLogPanics: true,
acceptEncoding: r.Header.Get("Accept-Encoding"),
}
// apply options
for _, opt := range opts {
opt(sseHandler)
}
// set compression encoding
if sseHandler.encoding != "" {
w.Header().Set("Content-Encoding", sseHandler.encoding)
}
// flush headers
if err := rc.Flush(); err != nil {
// Below panic is a deliberate choice as it should never occur and is an environment issue.
// https://crawshaw.io/blog/go-and-sqlite
// In Go, errors that are part of the standard operation of a program are returned as values.
// Programs are expected to handle errors.
panic(fmt.Sprintf("response writer failed to flush: %v", err))
}
return sseHandler
}
// Context returns the context associated with the upgraded connection.
// It is equivalent to calling [request.Context].
func (sse *ServerSentEventGenerator) Context() context.Context {
return sse.ctx
}
// serverSentEventData holds event configuration data for
// [SSEEventOption]s.
type serverSentEventData struct {
Type EventType
EventID string
Data []string
RetryDuration time.Duration
}
// SSEEventOption modifies one server-sent event.
type SSEEventOption func(*serverSentEventData)
// WithSSEEventId configures an optional event ID for one server-sent event.
// The client message field [lastEventId] will be set to this value.
// If the next event does not have an event ID, the last used event ID will remain.
//
// [lastEventId]: https://developer.mozilla.org/en-US/docs/Web/API/MessageEvent/lastEventId
func WithSSEEventId(id string) SSEEventOption {
return func(e *serverSentEventData) {
e.EventID = id
}
}
// WithSSERetryDuration overrides the [DefaultSseRetryDuration] for
// one server-sent event.
func WithSSERetryDuration(retryDuration time.Duration) SSEEventOption {
return func(e *serverSentEventData) {
e.RetryDuration = retryDuration
}
}
var (
eventLinePrefix = []byte("event: ")
idLinePrefix = []byte("id: ")
retryLinePrefix = []byte("retry: ")
dataLinePrefix = []byte("data: ")
)
func writeJustError(w io.Writer, b []byte) (err error) {
_, err = w.Write(b)
return err
}
// Send emits a server-sent event to the client. Method is safe for
// concurrent use.
func (sse *ServerSentEventGenerator) Send(eventType EventType, dataLines []string, opts ...SSEEventOption) error {
sse.mu.Lock()
defer sse.mu.Unlock()
// create the event
evt := serverSentEventData{
Type: eventType,
Data: dataLines,
RetryDuration: DefaultSseRetryDuration,
}
// apply options
for _, opt := range opts {
opt(&evt)
}
buf := bytebufferpool.Get()
defer bytebufferpool.Put(buf)
// write event type
if err := errors.Join(
writeJustError(buf, eventLinePrefix),
writeJustError(buf, []byte(evt.Type)),
writeJustError(buf, newLineBuf),
); err != nil {
return fmt.Errorf("failed to write event type: %w", err)
}
// write id if needed
if evt.EventID != "" {
if err := errors.Join(
writeJustError(buf, idLinePrefix),
writeJustError(buf, []byte(evt.EventID)),
writeJustError(buf, newLineBuf),
); err != nil {
return fmt.Errorf("failed to write id: %w", err)
}
}
// write retry if needed
if evt.RetryDuration.Milliseconds() > 0 && evt.RetryDuration.Milliseconds() != DefaultSseRetryDuration.Milliseconds() {
retry := int(evt.RetryDuration.Milliseconds())
retryStr := strconv.Itoa(retry)
if err := errors.Join(
writeJustError(buf, retryLinePrefix),
writeJustError(buf, []byte(retryStr)),
writeJustError(buf, newLineBuf),
); err != nil {
return fmt.Errorf("failed to write retry: %w", err)
}
}
// write data lines
for _, d := range evt.Data {
if err := errors.Join(
writeJustError(buf, dataLinePrefix),
writeJustError(buf, []byte(d)),
writeJustError(buf, newLineBuf),
); err != nil {
return fmt.Errorf("failed to write data: %w", err)
}
}
// write double newlines to separate events
if err := writeJustError(buf, doubleNewLineBuf); err != nil {
return fmt.Errorf("failed to write newline: %w", err)
}
// copy the buffer to the response writer
if _, err := buf.WriteTo(sse.w); err != nil {
return fmt.Errorf("failed to write to response writer: %w", err)
}
// flush the write if its a compressing writer
if f, ok := sse.w.(flusher); ok {
if err := f.Flush(); err != nil {
return fmt.Errorf("failed to flush compressing writer: %w", err)
}
}
if err := sse.rc.Flush(); err != nil {
return fmt.Errorf("failed to flush data: %w", err)
}
// log.Print(NewLine + buf.String())
return nil
}

15
mvac/datastar/types.go Normal file
View File

@ -0,0 +1,15 @@
package datastar
const (
NewLine = "\n"
DoubleNewLine = "\n\n"
)
var (
newLineBuf = []byte(NewLine)
doubleNewLineBuf = []byte(DoubleNewLine)
)
type flusher interface {
Flush() error
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,18 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=0" />
<script type="module" defer src="/datastar/datastar.js"></script>
<!-- <script src="tailwind/tailwind.js"></script> -->
<script src="unocss/unocss.js"></script>
<link rel="stylesheet" href="unocss/tailwind_reset.css" />
</head>
<body
style="display:flex;flex-direction:column;background-color:oklch(25.3267% 0.015896 252.417568);height:100vh;justify-content:center;align-items:center;font-family:ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';">
<span id="feed" data-on-load="@get('/stream')"></span>
</body>
</html>

409
mvac/frontend/index.html Normal file
View File

@ -0,0 +1,409 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Modern Landing Page</title>
<script type="module" defer src="/datastar/datastar.js"></script>
<!-- <script src="https://cdn.tailwindcss.com"></script> -->
<script src="unocss/unocss.js"></script>
<link rel="stylesheet" href="unocss/tailwind_reset.css" />
<!--
<script>const observer = new MutationObserver(() => { // move this into a central js file later
applyUnoStyle()
})
observer.observe(document.body, { childList: true, subtree: true })
</script> -->
<script>
document.addEventListener('DOMContentLoaded', () => { // move this into a central js file later
// const observer = new MutationObserver(() => {
// // applyUnoStyle()
// if (window.__unocss?.apply) {
// console.log('applying unocss');
// window.__unocss.apply()
// }
// })
// observer.observe(document.body, { childList: true, subtree: true })
// Simulate dynamic DOM mutation
const newDiv = document.createElement('div');
newDiv.className = 'text-cyan-700';
newDiv.textContent = 'Hello styled by UnoCSS!';
document.body.appendChild(newDiv);
})
</script>
<style>
/*move this into a central css file later */
[un-cloak] {
display: none;
}
</style>
</head>
<body class="bg-gray-50 font-sans" un-cloak>
<!-- Navigation -->
<nav class="bg-white shadow-lg fixed w-full top-0 z-50">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between h-16">
<div class="flex items-center">
<div class="flex-shrink-0">
<h1 class="text-2xl font-bold text-cyan-600">YourBrand</h1>
</div>
</div>
<div class="hidden md:flex items-center space-x-8">
<a href="#home"
class="text-gray-700 hover:text-cyan-600 px-3 py-2 text-md font-medium transition-colors">Home</a>
<a href="#about"
class="text-gray-700 hover:text-cyan-600 px-3 py-2 text-md font-medium transition-colors">About</a>
<a href="#services"
class="text-gray-700 hover:text-cyan-600 px-3 py-2 text-md font-medium transition-colors">Services</a>
<a href="#contact"
class="text-gray-700 hover:text-cyan-600 px-3 py-2 text-md font-medium transition-colors">Contact</a>
<button
class="bg-cyan-600 text-white px-4 py-2 rounded-md text-sm font-medium hover:bg-cyan-700 transition-colors">Get
Started</button>
</div>
<!-- Mobile menu button -->
<div class="md:hidden flex items-center">
<button class="text-gray-700 hover:text-cyan-600 focus:outline-none">
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M4 6h16M4 12h16M4 18h16" />
</svg>
</button>
</div>
</div>
</div>
</nav>
<!-- Hero Section -->
<section id="home" class="pt-16 bg-gradient-to-br from-cyan-50 via-white to-purple-50">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-20">
<div class="text-center">
<h1 class="text-4xl md:text-6xl font-bold text-gray-900 mb-6">
Build Something
<span class="text-cyan-600">Amazing</span>
</h1>
<p class="text-xl text-gray-600 mb-8 max-w-3xl mx-auto">
We help businesses create exceptional digital experiences that drive growth and engage customers
like never before.
</p>
<div class="space-x-4">
<button
class="bg-cyan-600 text-white px-8 py-3 rounded-lg text-lg font-semibold hover:bg-cyan-700 transform hover:scale-105 transition-all shadow-lg">
Get Started Today
</button>
<button
class="border-2 border-cyan-600 text-cyan-600 px-8 py-3 rounded-lg text-lg font-semibold hover:bg-cyan-50 transition-colors">
Learn More
</button>
</div>
</div>
</div>
</section>
<!-- Features Section -->
<section class="py-20 bg-white">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="text-center mb-16">
<h2 class="text-3xl md:text-4xl font-bold text-gray-900 mb-4">Why Choose Us</h2>
<div
style="display:flex;flex-direction:column;background-color:oklch(25.3267% 0.015896 252.417568);height:10rem;justify-content:center;align-items:center;font-family:ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';">
<span id="feed" data-on-load="@get('/stream')"></span>
</div>
<p class="text-xl text-gray-600 max-w-2xl mx-auto">
We deliver exceptional results through innovation, expertise, and dedication to your success.
</p>
</div>
<div class="grid md:grid-cols-3 gap-8">
<div class="text-center p-6 rounded-xl hover:shadow-lg transition-shadow">
<div class="bg-cyan-100 w-16 h-16 rounded-full flex items-center justify-center mx-auto mb-4">
<svg class="w-8 h-8 text-cyan-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M13 10V3L4 14h7v7l9-11h-7z"></path>
</svg>
</div>
<h3 class="text-xl font-semibold text-gray-900 mb-2">Lightning Fast</h3>
<p class="text-gray-600">Optimized performance that delivers results in milliseconds, not minutes.
</p>
</div>
<div class="text-center p-6 rounded-xl hover:shadow-lg transition-shadow">
<div class="bg-green-100 w-16 h-16 rounded-full flex items-center justify-center mx-auto mb-4">
<svg class="w-8 h-8 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
</div>
<h3 class="text-xl font-semibold text-gray-900 mb-2">Reliable</h3>
<p class="text-gray-600">99.9% uptime guarantee with enterprise-grade security and support.</p>
</div>
<div class="text-center p-6 rounded-xl hover:shadow-lg transition-shadow">
<div class="bg-indigo-100 w-16 h-16 rounded-full flex items-center justify-center mx-auto mb-4">
<svg class="w-8 h-8 text-indigo-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z">
</path>
</svg>
</div>
<h3 class="text-xl font-semibold text-gray-900 mb-2">User Focused!!</h3>
<p class="text-gray-600">Designed with your users in mind for the best possible experience.</p>
</div>
</div>
</div>
</section>
<!-- About Section -->
<section id="about" class="py-20 bg-gray-50">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="grid lg:grid-cols-2 gap-12 items-center">
<div>
<h2 class="text-3xl md:text-4xl font-bold text-gray-900 mb-6">About Our Company</h2>
<p class="text-lg text-gray-600 mb-6">
With over a decade of experience in digital innovation, we've helped hundreds of companies
transform their ideas into successful digital products.
</p>
<p class="text-lg text-gray-600 mb-8">
Our team of experts combines creativity with technical excellence to deliver solutions that not
only meet your needs but exceed your expectations.
</p>
<div class="space-y-4">
<div class="flex items-center">
<div class="bg-cyan-600 w-2 h-2 rounded-full mr-3"></div>
<span class="text-gray-700">10+ Years of Experience</span>
</div>
<div class="flex items-center">
<div class="bg-cyan-600 w-2 h-2 rounded-full mr-3"></div>
<span class="text-gray-700">500+ Successful Projects</span>
</div>
<div class="flex items-center">
<div class="bg-cyan-600 w-2 h-2 rounded-full mr-3"></div>
<span class="text-gray-700">24/7 Customer Support</span>
</div>
</div>
</div>
<div
class="bg-gradient-to-br from-cyan-400 to-purple-500 rounded-2xl h-96 flex items-center justify-center">
<div class="text-white text-center">
<div class="text-6xl mb-4">🚀</div>
<p class="text-xl font-semibold">Innovation at Its Best</p>
</div>
</div>
</div>
</div>
</section>
<!-- Services Section -->
<section id="services" class="py-20 bg-white">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="text-center mb-16">
<h2 class="text-3xl md:text-4xl font-bold text-gray-900 mb-4">Our Services</h2>
<p class="text-xl text-gray-600 max-w-2xl mx-auto">
Comprehensive solutions tailored to meet your unique business needs and drive growth.
</p>
</div>
<div class="grid md:grid-cols-2 lg:grid-cols-3 gap-8">
<div class="bg-white border border-gray-200 rounded-xl p-6 hover:shadow-xl transition-shadow">
<div class="bg-blue-50 w-12 h-12 rounded-lg flex items-center justify-center mb-4">
<svg class="w-6 h-6 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4"></path>
</svg>
</div>
<h3 class="text-xl font-semibold text-gray-900 mb-2">Web Development</h3>
<p class="text-gray-600">Custom web applications built with modern technologies and best practices.
</p>
</div>
<div class="bg-white border border-gray-200 rounded-xl p-6 hover:shadow-xl transition-shadow">
<div class="bg-green-50 w-12 h-12 rounded-lg flex items-center justify-center mb-4">
<svg class="w-6 h-6 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 18h.01M8 21h8a2 2 0 002-2V5a2 2 0 00-2-2H8a2 2 0 00-2 2v14a2 2 0 002 2z"></path>
</svg>
</div>
<h3 class="text-xl font-semibold text-gray-900 mb-2">Mobile Apps</h3>
<p class="text-gray-600">Native and cross-platform mobile applications for iOS and Android.</p>
</div>
<div class="bg-white border border-gray-200 rounded-xl p-6 hover:shadow-xl transition-shadow">
<div class="bg-indigo-50 w-12 h-12 rounded-lg flex items-center justify-center mb-4">
<svg class="w-6 h-6 text-indigo-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zM21 5a2 2 0 00-2-2h-4a2 2 0 00-2 2v12a4 4 0 004 4h4a2 2 0 002-2V5z">
</path>
</svg>
</div>
<h3 class="text-xl font-semibold text-gray-900 mb-2">UI/UX Design</h3>
<p class="text-gray-600">Beautiful, intuitive designs that enhance user experience and engagement.
</p>
</div>
</div>
</div>
</section>
<!-- Contact Section -->
<section id="contact" class="py-20 bg-gray-900 text-white">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="text-center mb-16">
<h2 class="text-3xl md:text-4xl font-bold mb-4">Get In Touch</h2>
<p class="text-xl text-gray-300 max-w-2xl mx-auto">
Ready to start your next project? We'd love to hear from you and discuss how we can help.
</p>
</div>
<div class="grid md:grid-cols-2 gap-12">
<div>
<h3 class="text-2xl font-semibold mb-6">Contact Information</h3>
<div class="space-y-4">
<div class="flex items-center">
<div class="bg-cyan-600 w-10 h-10 rounded-full flex items-center justify-center mr-4">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M3 8l7.89 4.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z">
</path>
</svg>
</div>
<div>
<p class="font-semibold">Email</p>
<p class="text-gray-300">hello@yourbrand.com</p>
</div>
</div>
<div class="flex items-center">
<div class="bg-cyan-600 w-10 h-10 rounded-full flex items-center justify-center mr-4">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z">
</path>
</svg>
</div>
<div>
<p class="font-semibold">Phone</p>
<p class="text-gray-300">+1 (555) 123-4567</p>
</div>
</div>
<div class="flex items-center">
<div class="bg-cyan-600 w-10 h-10 rounded-full flex items-center justify-center mr-4">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z">
</path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"></path>
</svg>
</div>
<div>
<p class="font-semibold">Address</p>
<p class="text-gray-300">123 Business St, City, State 12345</p>
</div>
</div>
</div>
</div>
<div>
<form class="space-y-6">
<div>
<input type="text" placeholder="Your Name"
class="w-full px-4 py-3 bg-gray-800 text-white rounded-lg focus:outline-none focus:ring-2 focus:ring-cyan-500">
</div>
<div>
<input type="email" placeholder="Your Email"
class="w-full px-4 py-3 bg-gray-800 text-white rounded-lg focus:outline-none focus:ring-2 focus:ring-cyan-500">
</div>
<div>
<textarea rows="4" placeholder="Your Message"
class="w-full px-4 py-3 bg-gray-800 text-white rounded-lg focus:outline-none focus:ring-2 focus:ring-cyan-500"></textarea>
</div>
<button
class="w-full bg-cyan-600 text-white py-3 rounded-lg font-semibold hover:bg-cyan-700 transition-colors">
Send Message
</button>
</form>
</div>
</div>
</div>
</section>
<!-- Footer -->
<footer class="bg-black text-white py-12">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="grid md:grid-cols-4 gap-8">
<div>
<h3 class="text-xl font-bold text-cyan-400 mb-4">YourBrand</h3>
<p class="text-gray-400">Building amazing digital experiences that make a difference.</p>
</div>
<div>
<h4 class="font-semibold mb-4">Services</h4>
<ul class="space-y-2 text-gray-400">
<li><a href="#" class="hover:text-white transition-colors">Web Development</a></li>
<li><a href="#" class="hover:text-white transition-colors">Mobile Apps</a></li>
<li><a href="#" class="hover:text-white transition-colors">UI/UX Design</a></li>
</ul>
</div>
<div>
<h4 class="font-semibold mb-4">Company</h4>
<ul class="space-y-2 text-gray-400">
<li><a href="#" class="hover:text-white transition-colors">About Us</a></li>
<li><a href="#" class="hover:text-white transition-colors">Our Team</a></li>
<li><a href="#" class="hover:text-white transition-colors">Careers</a></li>
</ul>
</div>
<div>
<h4 class="font-semibold mb-4">Follow Us</h4>
<div class="flex space-x-4">
<a href="#" class="text-gray-400 hover:text-white transition-colors">
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
<path
d="M24 4.557c-.883.392-1.832.656-2.828.775 1.017-.609 1.798-1.574 2.165-2.724-.951.564-2.005.974-3.127 1.195-.897-.957-2.178-1.555-3.594-1.555-3.179 0-5.515 2.966-4.797 6.045-4.091-.205-7.719-2.165-10.148-5.144-1.29 2.213-.669 5.108 1.523 6.574-.806-.026-1.566-.247-2.229-.616-.054 2.281 1.581 4.415 3.949 4.89-.693.188-1.452.232-2.224.084.626 1.956 2.444 3.379 4.6 3.419-2.07 1.623-4.678 2.348-7.29 2.04 2.179 1.397 4.768 2.212 7.548 2.212 9.142 0 14.307-7.721 13.995-14.646.962-.695 1.797-1.562 2.457-2.549z" />
</svg>
</a>
<a href="#" class="text-gray-400 hover:text-white transition-colors">
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
<path
d="M22.46 6c-.77.35-1.6.58-2.46.69.88-.53 1.56-1.37 1.88-2.38-.83.5-1.75.85-2.72 1.05C18.37 4.5 17.26 4 16 4c-2.35 0-4.27 1.92-4.27 4.29 0 .34.04.67.11.98C8.28 9.09 5.11 7.38 3 4.79c-.37.63-.58 1.37-.58 2.15 0 1.49.75 2.81 1.91 3.56-.71 0-1.37-.2-1.95-.5v.03c0 2.08 1.48 3.82 3.44 4.21a4.22 4.22 0 0 1-1.93.07 4.28 4.28 0 0 0 4 2.98 8.521 8.521 0 0 1-5.33 1.84c-.34 0-.68-.02-1.02-.06C3.44 20.29 5.7 21 8.12 21 16 21 20.33 14.46 20.33 8.79c0-.19 0-.37-.01-.56.84-.6 1.56-1.36 2.14-2.23z" />
</svg>
</a>
<a href="#" class="text-gray-400 hover:text-white transition-colors">
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
<path
d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z" />
</svg>
</a>
</div>
</div>
</div>
<div class="border-t border-gray-800 mt-8 pt-8 text-center text-gray-400">
<p>&copy; 2025 YourBrand. All rights reserved.</p>
</div>
</div>
</footer>
<script>
// Smooth scrolling for navigation links
document.querySelectorAll('a[href^="#"]').forEach(anchor => {
anchor.addEventListener('click', function (e) {
e.preventDefault();
const target = document.querySelector(this.getAttribute('href'));
if (target) {
target.scrollIntoView({
behavior: 'smooth',
block: 'start'
});
}
});
});
// Add some interactivity to buttons
document.querySelectorAll('button').forEach(button => {
button.addEventListener('mouseenter', function () {
this.style.transform = 'scale(1.05)';
});
button.addEventListener('mouseleave', function () {
this.style.transform = 'scale(1)';
});
});
</script>
</body>
</html>

View File

@ -0,0 +1,380 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Modern Landing Page</title>
<script type="module" defer src="/datastar/datastar.js"></script>
<!-- <script src="https://cdn.tailwindcss.com"></script> -->
<script src="unocss/unocss.js"></script>
<link rel="stylesheet" href="unocss/tailwind_reset.css" />
<style>
[un-cloak] {
display: none;
}
</style>
</head>
<body class="bg-gray-50 font-sans" un-cloak>
<!-- Navigation -->
<nav class="bg-white shadow-lg fixed w-full top-0 z-50">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between h-16">
<div class="flex items-center">
<div class="flex-shrink-0">
<h1 class="text-2xl font-bold text-cyan-600">YourBrand</h1>
</div>
</div>
<div class="hidden md:flex items-center space-x-8">
<a href="#home"
class="text-gray-700 hover:text-cyan-600 px-3 py-2 text-sm font-medium transition-colors">Home</a>
<a href="#about"
class="text-gray-700 hover:text-cyan-600 px-3 py-2 text-sm font-medium transition-colors">About</a>
<a href="#services"
class="text-gray-700 hover:text-cyan-600 px-3 py-2 text-sm font-medium transition-colors">Services</a>
<a href="#contact"
class="text-gray-700 hover:text-cyan-600 px-3 py-2 text-sm font-medium transition-colors">Contact</a>
<button
class="bg-cyan-600 text-white px-4 py-2 rounded-md text-sm font-medium hover:bg-cyan-700 transition-colors">Get
Started</button>
</div>
<!-- Mobile menu button -->
<div class="md:hidden flex items-center">
<button class="text-gray-700 hover:text-cyan-600 focus:outline-none">
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M4 6h16M4 12h16M4 18h16" />
</svg>
</button>
</div>
</div>
</div>
</nav>
<!-- Hero Section -->
<section id="home" class="pt-16 bg-gradient-to-br from-cyan-50 via-white to-purple-50">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-20">
<div class="text-center">
<h1 class="text-4xl md:text-6xl font-bold text-gray-900 mb-6">
Build Something
<span class="text-cyan-600">Amazing</span>
</h1>
<p class="text-xl text-gray-600 mb-8 max-w-3xl mx-auto">
We help businesses create exceptional digital experiences that drive growth and engage customers
like never before.
</p>
<div class="space-x-4">
<button
class="bg-cyan-600 text-white px-8 py-3 rounded-lg text-lg font-semibold hover:bg-cyan-700 transform hover:scale-105 transition-all shadow-lg">
Get Started Today
</button>
<button
class="border-2 border-cyan-600 text-cyan-600 px-8 py-3 rounded-lg text-lg font-semibold hover:bg-cyan-50 transition-colors">
Learn More
</button>
</div>
</div>
</div>
</section>
<!-- Features Section -->
<section class="py-20 bg-white">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="text-center mb-16">
<h2 class="text-3xl md:text-4xl font-bold text-gray-900 mb-4">Why Choose Us</h2>
<div
style="display:flex;flex-direction:column;background-color:oklch(25.3267% 0.015896 252.417568);height:10rem;justify-content:center;align-items:center;font-family:ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';">
<span id="feed" data-on-load="@get('/stream')"></span>
</div>
<p class="text-xl text-gray-600 max-w-2xl mx-auto">
We deliver exceptional results through innovation, expertise, and dedication to your success.
</p>
</div>
<div class="grid md:grid-cols-3 gap-8">
<div class="text-center p-6 rounded-xl hover:shadow-lg transition-shadow">
<div class="bg-cyan-100 w-16 h-16 rounded-full flex items-center justify-center mx-auto mb-4">
<svg class="w-8 h-8 text-cyan-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M13 10V3L4 14h7v7l9-11h-7z"></path>
</svg>
</div>
<h3 class="text-xl font-semibold text-gray-900 mb-2">Lightning Fast</h3>
<p class="text-gray-600">Optimized performance that delivers results in milliseconds, not minutes.
</p>
</div>
<div class="text-center p-6 rounded-xl hover:shadow-lg transition-shadow">
<div class="bg-green-100 w-16 h-16 rounded-full flex items-center justify-center mx-auto mb-4">
<svg class="w-8 h-8 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
</div>
<h3 class="text-xl font-semibold text-gray-900 mb-2">Reliable</h3>
<p class="text-gray-600">99.9% uptime guarantee with enterprise-grade security and support.</p>
</div>
<div class="text-center p-6 rounded-xl hover:shadow-lg transition-shadow">
<div class="bg-indigo-100 w-16 h-16 rounded-full flex items-center justify-center mx-auto mb-4">
<svg class="w-8 h-8 text-indigo-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z">
</path>
</svg>
</div>
<h3 class="text-xl font-semibold text-gray-900 mb-2">User Focused!!</h3>
<p class="text-gray-600">Designed with your users in mind for the best possible experience.</p>
</div>
</div>
</div>
</section>
<!-- About Section -->
<section id="about" class="py-20 bg-gray-50">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="grid lg:grid-cols-2 gap-12 items-center">
<div>
<h2 class="text-3xl md:text-4xl font-bold text-gray-900 mb-6">About Our Company</h2>
<p class="text-lg text-gray-600 mb-6">
With over a decade of experience in digital innovation, we've helped hundreds of companies
transform their ideas into successful digital products.
</p>
<p class="text-lg text-gray-600 mb-8">
Our team of experts combines creativity with technical excellence to deliver solutions that not
only meet your needs but exceed your expectations.
</p>
<div class="space-y-4">
<div class="flex items-center">
<div class="bg-cyan-600 w-2 h-2 rounded-full mr-3"></div>
<span class="text-gray-700">10+ Years of Experience</span>
</div>
<div class="flex items-center">
<div class="bg-cyan-600 w-2 h-2 rounded-full mr-3"></div>
<span class="text-gray-700">500+ Successful Projects</span>
</div>
<div class="flex items-center">
<div class="bg-cyan-600 w-2 h-2 rounded-full mr-3"></div>
<span class="text-gray-700">24/7 Customer Support</span>
</div>
</div>
</div>
<div
class="bg-gradient-to-br from-cyan-400 to-purple-500 rounded-2xl h-96 flex items-center justify-center">
<div class="text-white text-center">
<div class="text-6xl mb-4">🚀</div>
<p class="text-xl font-semibold">Innovation at Its Best</p>
</div>
</div>
</div>
</div>
</section>
<!-- Services Section -->
<section id="services" class="py-20 bg-white">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="text-center mb-16">
<h2 class="text-3xl md:text-4xl font-bold text-gray-900 mb-4">Our Services</h2>
<p class="text-xl text-gray-600 max-w-2xl mx-auto">
Comprehensive solutions tailored to meet your unique business needs and drive growth.
</p>
</div>
<div class="grid md:grid-cols-2 lg:grid-cols-3 gap-8">
<div class="bg-white border border-gray-200 rounded-xl p-6 hover:shadow-xl transition-shadow">
<div class="bg-blue-50 w-12 h-12 rounded-lg flex items-center justify-center mb-4">
<svg class="w-6 h-6 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4"></path>
</svg>
</div>
<h3 class="text-xl font-semibold text-gray-900 mb-2">Web Development</h3>
<p class="text-gray-600">Custom web applications built with modern technologies and best practices.
</p>
</div>
<div class="bg-white border border-gray-200 rounded-xl p-6 hover:shadow-xl transition-shadow">
<div class="bg-green-50 w-12 h-12 rounded-lg flex items-center justify-center mb-4">
<svg class="w-6 h-6 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 18h.01M8 21h8a2 2 0 002-2V5a2 2 0 00-2-2H8a2 2 0 00-2 2v14a2 2 0 002 2z"></path>
</svg>
</div>
<h3 class="text-xl font-semibold text-gray-900 mb-2">Mobile Apps</h3>
<p class="text-gray-600">Native and cross-platform mobile applications for iOS and Android.</p>
</div>
<div class="bg-white border border-gray-200 rounded-xl p-6 hover:shadow-xl transition-shadow">
<div class="bg-indigo-50 w-12 h-12 rounded-lg flex items-center justify-center mb-4">
<svg class="w-6 h-6 text-indigo-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zM21 5a2 2 0 00-2-2h-4a2 2 0 00-2 2v12a4 4 0 004 4h4a2 2 0 002-2V5z">
</path>
</svg>
</div>
<h3 class="text-xl font-semibold text-gray-900 mb-2">UI/UX Design</h3>
<p class="text-gray-600">Beautiful, intuitive designs that enhance user experience and engagement.
</p>
</div>
</div>
</div>
</section>
<!-- Contact Section -->
<section id="contact" class="py-20 bg-gray-900 text-white">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="text-center mb-16">
<h2 class="text-3xl md:text-4xl font-bold mb-4">Get In Touch</h2>
<p class="text-xl text-gray-300 max-w-2xl mx-auto">
Ready to start your next project? We'd love to hear from you and discuss how we can help.
</p>
</div>
<div class="grid md:grid-cols-2 gap-12">
<div>
<h3 class="text-2xl font-semibold mb-6">Contact Information</h3>
<div class="space-y-4">
<div class="flex items-center">
<div class="bg-cyan-600 w-10 h-10 rounded-full flex items-center justify-center mr-4">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M3 8l7.89 4.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z">
</path>
</svg>
</div>
<div>
<p class="font-semibold">Email</p>
<p class="text-gray-300">hello@yourbrand.com</p>
</div>
</div>
<div class="flex items-center">
<div class="bg-cyan-600 w-10 h-10 rounded-full flex items-center justify-center mr-4">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z">
</path>
</svg>
</div>
<div>
<p class="font-semibold">Phone</p>
<p class="text-gray-300">+1 (555) 123-4567</p>
</div>
</div>
<div class="flex items-center">
<div class="bg-cyan-600 w-10 h-10 rounded-full flex items-center justify-center mr-4">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z">
</path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"></path>
</svg>
</div>
<div>
<p class="font-semibold">Address</p>
<p class="text-gray-300">123 Business St, City, State 12345</p>
</div>
</div>
</div>
</div>
<div>
<form class="space-y-6">
<div>
<input type="text" placeholder="Your Name"
class="w-full px-4 py-3 bg-gray-800 text-white rounded-lg focus:outline-none focus:ring-2 focus:ring-cyan-500">
</div>
<div>
<input type="email" placeholder="Your Email"
class="w-full px-4 py-3 bg-gray-800 text-white rounded-lg focus:outline-none focus:ring-2 focus:ring-cyan-500">
</div>
<div>
<textarea rows="4" placeholder="Your Message"
class="w-full px-4 py-3 bg-gray-800 text-white rounded-lg focus:outline-none focus:ring-2 focus:ring-cyan-500"></textarea>
</div>
<button
class="w-full bg-cyan-600 text-white py-3 rounded-lg font-semibold hover:bg-cyan-700 transition-colors">
Send Message
</button>
</form>
</div>
</div>
</div>
</section>
<!-- Footer -->
<footer class="bg-black text-white py-12">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="grid md:grid-cols-4 gap-8">
<div>
<h3 class="text-xl font-bold text-cyan-400 mb-4">YourBrand</h3>
<p class="text-gray-400">Building amazing digital experiences that make a difference.</p>
</div>
<div>
<h4 class="font-semibold mb-4">Services</h4>
<ul class="space-y-2 text-gray-400">
<li><a href="#" class="hover:text-white transition-colors">Web Development</a></li>
<li><a href="#" class="hover:text-white transition-colors">Mobile Apps</a></li>
<li><a href="#" class="hover:text-white transition-colors">UI/UX Design</a></li>
</ul>
</div>
<div>
<h4 class="font-semibold mb-4">Company</h4>
<ul class="space-y-2 text-gray-400">
<li><a href="#" class="hover:text-white transition-colors">About Us</a></li>
<li><a href="#" class="hover:text-white transition-colors">Our Team</a></li>
<li><a href="#" class="hover:text-white transition-colors">Careers</a></li>
</ul>
</div>
<div>
<h4 class="font-semibold mb-4">Follow Us</h4>
<div class="flex space-x-4">
<a href="#" class="text-gray-400 hover:text-white transition-colors">
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
<path
d="M24 4.557c-.883.392-1.832.656-2.828.775 1.017-.609 1.798-1.574 2.165-2.724-.951.564-2.005.974-3.127 1.195-.897-.957-2.178-1.555-3.594-1.555-3.179 0-5.515 2.966-4.797 6.045-4.091-.205-7.719-2.165-10.148-5.144-1.29 2.213-.669 5.108 1.523 6.574-.806-.026-1.566-.247-2.229-.616-.054 2.281 1.581 4.415 3.949 4.89-.693.188-1.452.232-2.224.084.626 1.956 2.444 3.379 4.6 3.419-2.07 1.623-4.678 2.348-7.29 2.04 2.179 1.397 4.768 2.212 7.548 2.212 9.142 0 14.307-7.721 13.995-14.646.962-.695 1.797-1.562 2.457-2.549z" />
</svg>
</a>
<a href="#" class="text-gray-400 hover:text-white transition-colors">
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
<path
d="M22.46 6c-.77.35-1.6.58-2.46.69.88-.53 1.56-1.37 1.88-2.38-.83.5-1.75.85-2.72 1.05C18.37 4.5 17.26 4 16 4c-2.35 0-4.27 1.92-4.27 4.29 0 .34.04.67.11.98C8.28 9.09 5.11 7.38 3 4.79c-.37.63-.58 1.37-.58 2.15 0 1.49.75 2.81 1.91 3.56-.71 0-1.37-.2-1.95-.5v.03c0 2.08 1.48 3.82 3.44 4.21a4.22 4.22 0 0 1-1.93.07 4.28 4.28 0 0 0 4 2.98 8.521 8.521 0 0 1-5.33 1.84c-.34 0-.68-.02-1.02-.06C3.44 20.29 5.7 21 8.12 21 16 21 20.33 14.46 20.33 8.79c0-.19 0-.37-.01-.56.84-.6 1.56-1.36 2.14-2.23z" />
</svg>
</a>
<a href="#" class="text-gray-400 hover:text-white transition-colors">
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
<path
d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z" />
</svg>
</a>
</div>
</div>
</div>
<div class="border-t border-gray-800 mt-8 pt-8 text-center text-gray-400">
<p>&copy; 2025 YourBrand. All rights reserved.</p>
</div>
</div>
</footer>
<script>
// Smooth scrolling for navigation links
document.querySelectorAll('a[href^="#"]').forEach(anchor => {
anchor.addEventListener('click', function (e) {
e.preventDefault();
const target = document.querySelector(this.getAttribute('href'));
if (target) {
target.scrollIntoView({
behavior: 'smooth',
block: 'start'
});
}
});
});
// Add some interactivity to buttons
document.querySelectorAll('button').forEach(button => {
button.addEventListener('mouseenter', function () {
this.style.transform = 'scale(1.05)';
});
button.addEventListener('mouseleave', function () {
this.style.transform = 'scale(1)';
});
});
</script>
</body>
</html>

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,8 @@
/**
* Minified by jsDelivr using clean-css v5.3.3.
* Original file: /npm/@unocss/reset@66.1.2/tailwind.css
*
* Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files
*/
*,::after,::before{box-sizing:border-box;border-width:0;border-style:solid;border-color:var(--un-default-border-color,#e5e7eb)}::after,::before{--un-content:''}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0;padding:0}legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}
/*# sourceMappingURL=/sm/f313dd8f516db176bb3bc52901eb0cbd46e85903faf39d220fe82788a3a65d7d.map */

File diff suppressed because one or more lines are too long

22
mvac/go.mod Normal file
View File

@ -0,0 +1,22 @@
module mva
go 1.24.3
require modernc.org/sqlite v1.37.1
require (
github.com/CAFxX/httpcompression v0.0.9 // indirect
github.com/andybalholm/brotli v1.0.5 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/klauspost/compress v1.18.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6 // indirect
golang.org/x/sys v0.33.0 // indirect
modernc.org/libc v1.65.8 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
)

77
mvac/go.sum Normal file
View File

@ -0,0 +1,77 @@
github.com/CAFxX/httpcompression v0.0.9 h1:0ue2X8dOLEpxTm8tt+OdHcgA+gbDge0OqFQWGKSqgrg=
github.com/CAFxX/httpcompression v0.0.9/go.mod h1:XX8oPZA+4IDcfZ0A71Hz0mZsv/YJOgYygkFhizVPilM=
github.com/andybalholm/brotli v1.0.5 h1:8uQZIdzKmjc/iuPu7O2ioW48L81FgatrcpfFmiq/cCs=
github.com/andybalholm/brotli v1.0.5/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/google/brotli/go/cbrotli v0.0.0-20230829110029-ed738e842d2f/go.mod h1:nOPhAkwVliJdNTkj3gXpljmWhjc4wCaVqbMJcPKWP4s=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/klauspost/compress v1.16.7/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/klauspost/pgzip v1.2.6/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pierrec/lz4/v4 v4.1.18/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/ulikunitz/xz v0.5.11/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/gozstd v1.20.1/go.mod h1:y5Ew47GLlP37EkTB+B4s7r6A5rdaeB7ftbl9zoYiIPQ=
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM=
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8=
golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6 h1:y5zboxd6LQAqYIhHnB48p0ByQ/GnQx2BE33L8BOHQkI=
golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6/go.mod h1:U6Lno4MTRCDY+Ba7aCcauB9T60gsv5s4ralQzP72ZoQ=
golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU=
golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ=
golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/tools v0.33.0 h1:4qz2S3zmRxbGIhDIAgjxvFutSvH5EfnsYrRBj0UI0bc=
golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.26.1 h1:+X5NtzVBn0KgsBCBe+xkDC7twLb/jNVj9FPgiwSQO3s=
modernc.org/cc/v4 v4.26.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU=
modernc.org/ccgo/v4 v4.28.0/go.mod h1:JygV3+9AV6SmPhDasu4JgquwU81XAKLd3OKTUDNOiKE=
modernc.org/fileutil v1.3.1 h1:8vq5fe7jdtEvoCf3Zf9Nm0Q05sH6kGx0Op2CPx1wTC8=
modernc.org/fileutil v1.3.1/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/libc v1.65.7 h1:Ia9Z4yzZtWNtUIuiPuQ7Qf7kxYrxP1/jeHZzG8bFu00=
modernc.org/libc v1.65.7/go.mod h1:011EQibzzio/VX3ygj1qGFt5kMjP0lHb0qCW5/D/pQU=
modernc.org/libc v1.65.8 h1:7PXRJai0TXZ8uNA3srsmYzmTyrLoHImV5QxHeni108Q=
modernc.org/libc v1.65.8/go.mod h1:011EQibzzio/VX3ygj1qGFt5kMjP0lHb0qCW5/D/pQU=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.37.1 h1:EgHJK/FPoqC+q2YBXg7fUmES37pCHFc97sI7zSayBEs=
modernc.org/sqlite v1.37.1/go.mod h1:XwdRtsE1MpiBcL54+MbKcaDvcuej+IYSMfLN6gSKV8g=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=

252
mvac/main_dev.go Normal file
View File

@ -0,0 +1,252 @@
//go:build !prod
// +build !prod
package main
import (
"context"
"crypto/rand"
"embed"
"encoding/hex"
"fmt"
"io/fs"
"log"
"log/slog"
"mva/datastar"
"mva/sqlite"
"net/http"
"os"
"os/signal"
"path/filepath"
"strings"
"sync"
"time"
)
const (
exitCodeErr = 1
exitCodeInterrupt = 2
)
//go:embed frontend
var frontend embed.FS
// //go:embed templates
// var templates embed.FS
// var templ = template.Must(template.ParseFS(templates, "templates/*.html"))
// main is the entry point of the application.
// Its task is to check wether all execution conditions are fullfilled.
// Collecting information from the environment: flags, environment vars, configs.
// Calling the run() function.
func main() {
fmt.Println("Developement mode")
ctx := context.Background()
// logging
logFileName := "./mva.log"
logFile, err := os.OpenFile(logFileName, os.O_RDWR|os.O_CREATE|os.O_APPEND, 0666)
if err != nil {
log.Printf("error opening file: %v", err)
os.Exit(exitCodeErr)
}
defer logFile.Close()
log.SetOutput(logFile)
// database
dbName := "./database/advendtureworks.db"
db := sqlite.New(dbName)
if err != nil {
log.Printf("Failed to open %s database: %v", dbName, err)
os.Exit(exitCodeErr)
}
// run the app
if err := run(db, ctx); err != nil {
fmt.Fprintf(os.Stderr, "%s\n", err)
os.Exit(exitCodeErr)
}
}
// Setting up all dependencies
// Creating the server (a central http handler)
func run(db *sqlite.Database, ctx context.Context) error {
ctx, cancel := signal.NotifyContext(ctx, os.Interrupt)
defer cancel()
err := db.Open(ctx)
if err != nil {
log.Printf("Failed to open %s database: %v", db.Name(), err)
os.Exit(exitCodeErr)
}
defer db.Close()
server := NewServer(db)
httpServer := &http.Server{
Addr: ":8080",
Handler: server,
}
go func() {
log.Printf("listening on %s\n", httpServer.Addr)
if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
fmt.Fprintf(os.Stderr, "error listening and serving: %s\n", err)
}
}()
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
<-ctx.Done()
// make a new context for the Shutdown (thanks Alessandro Rosetti)
// shutdownCtx := context.Background()
shutdownCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
if err := httpServer.Shutdown(shutdownCtx); err != nil {
fmt.Fprintf(os.Stderr, "error shutting down http server: %s\n", err)
}
log.Printf("shut down http server on %s\n", httpServer.Addr)
}()
wg.Wait()
return nil
}
// The NewServer constructor is responsible for all the top-level HTTP stuff that applies to all endpoints, like CORS, auth middleware, and logging:
func NewServer(db *sqlite.Database) http.Handler {
mux := http.NewServeMux()
static, err := fs.Sub(frontend, "frontend")
if err != nil {
log.Fatal(err)
}
// Print the embedded filesystem tree
fmt.Println("Embedded frontend filesystem tree:")
err = printFSTree(static, ".", 0)
if err != nil {
log.Printf("Error printing filesystem tree: %v\n", err)
}
fmt.Println("--- End of tree ---")
addRoutes(
mux,
static,
nil, // templ
)
mux.HandleFunc("GET /stream", func(w http.ResponseWriter, r *http.Request) {
ticker := time.NewTicker(1000 * time.Millisecond)
// original: defer ticker.Stop()
defer func() {
fmt.Println("defer executed")
ticker.Stop()
}()
sse := datastar.NewSSE(w, r)
for {
select {
case <-r.Context().Done():
slog.Debug("Client connection closed")
return
case <-ticker.C:
bytes := make([]byte, 3)
if _, err := rand.Read(bytes); err != nil {
slog.Error("Error generating random bytes: ", slog.String("error", err.Error()))
return
}
hexString := hex.EncodeToString(bytes)
frag := fmt.Sprintf(`<span id="feed" style="color:#%s;border:1px solid #%s;border-radius:0.25rem;padding:1rem;">%s</span>`, hexString, hexString, hexString)
sse.MergeFragments(frag)
}
}
})
var handler http.Handler = mux
// handler = authMiddleware(handler)
handler = headerMiddleware(handler)
return handler
}
// printFSTree prints a tree-like structure of the given filesystem.
func printFSTree(efs fs.FS, root string, indentLevel int) error {
return fs.WalkDir(efs, root, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
// Skip the root directory itself for cleaner output if it's "."
if path == "." && root == "." {
return nil
}
indent := strings.Repeat("│ ", indentLevel)
connector := "├── "
// For the last item in a directory, use a different connector.
// This requires knowing if it's the last item, which fs.WalkDir doesn't directly provide.
// For simplicity, we'll use the same connector for all items.
// A more sophisticated approach would involve reading directory entries first.
fmt.Printf("%s%s%s\n", indent, connector, filepath.Base(path))
if d.IsDir() && path != root { // Avoid infinite recursion for the root itself if not handled carefully
// The WalkDir function handles recursion, so we don't need to call printFSTree recursively here.
// We adjust indentLevel based on path depth for visual representation.
// This simple indentation based on WalkDir's path might not be perfect for deep structures
// but gives a good overview.
}
return nil
})
}
// authMiddleware is a simple authentication middleware
// func authMiddleware(next http.Handler) http.Handler {
// return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// user, pass, ok := r.BasicAuth()
// if !ok || !validateUser(user, pass) {
// w.Header().Set("WWW-Authenticate", `Basic realm="Please enter your credentials"`)
// http.Error(w, "Unauthorized", http.StatusUnauthorized)
// return
// }
// next.ServeHTTP(w, r)
// })
// }
// authMiddleware is a simple authentication middleware
func headerMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Println("Request URL:", r.URL.String())
fmt.Println("Request Headers:")
for key, values := range r.Header {
for _, value := range values {
if key == "Referer" || strings.HasPrefix(key, "Hx") {
fmt.Printf("%s: %s\n", key, value)
}
}
}
fmt.Println()
next.ServeHTTP(w, r)
})
}
// validateUser validates the user credentials
func validateUser(username, password string) bool {
// In a real application, these credentials should be stored securely.
return strings.EqualFold(username, "admin") && password == "password"
}

252
mvac/main_prod.go Normal file
View File

@ -0,0 +1,252 @@
//go:build prod
// +build prod
package main
import (
"context"
"crypto/rand"
"embed"
"encoding/hex"
"fmt"
"io/fs"
"log"
"log/slog"
"mva/datastar"
"mva/sqlite"
"net/http"
"os"
"os/signal"
"path/filepath"
"strings"
"sync"
"time"
)
const (
exitCodeErr = 1
exitCodeInterrupt = 2
)
//go:embed frontend
var frontend embed.FS
// //go:embed templates
// var templates embed.FS
// var templ = template.Must(template.ParseFS(templates, "templates/*.html"))
// main is the entry point of the application.
// Its task is to check wether all execution conditions are fullfilled.
// Collecting information from the environment: flags, environment vars, configs.
// Calling the run() function.
func main() {
fmt.Println("Production mode")
ctx := context.Background()
// logging
logFileName := "./mva.log"
logFile, err := os.OpenFile(logFileName, os.O_RDWR|os.O_CREATE|os.O_APPEND, 0666)
if err != nil {
log.Printf("error opening file: %v", err)
os.Exit(exitCodeErr)
}
defer logFile.Close()
log.SetOutput(logFile)
// database
dbName := "./database/advendtureworks.db"
db := sqlite.New(dbName)
if err != nil {
log.Printf("Failed to open %s database: %v", dbName, err)
os.Exit(exitCodeErr)
}
// run the app
if err := run(db, ctx); err != nil {
fmt.Fprintf(os.Stderr, "%s\n", err)
os.Exit(exitCodeErr)
}
}
// Setting up all dependencies
// Creating the server (a central http handler)
func run(db *sqlite.Database, ctx context.Context) error {
ctx, cancel := signal.NotifyContext(ctx, os.Interrupt)
defer cancel()
err := db.Open(ctx)
if err != nil {
log.Printf("Failed to open %s database: %v", db.Name(), err)
os.Exit(exitCodeErr)
}
defer db.Close()
server := NewServer(db)
httpServer := &http.Server{
Addr: ":8080",
Handler: server,
}
go func() {
log.Printf("listening on %s\n", httpServer.Addr)
if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
fmt.Fprintf(os.Stderr, "error listening and serving: %s\n", err)
}
}()
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
<-ctx.Done()
// make a new context for the Shutdown (thanks Alessandro Rosetti)
// shutdownCtx := context.Background()
shutdownCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
if err := httpServer.Shutdown(shutdownCtx); err != nil {
fmt.Fprintf(os.Stderr, "error shutting down http server: %s\n", err)
}
log.Printf("shut down http server on %s\n", httpServer.Addr)
}()
wg.Wait()
return nil
}
// The NewServer constructor is responsible for all the top-level HTTP stuff that applies to all endpoints, like CORS, auth middleware, and logging:
func NewServer(db *sqlite.Database) http.Handler {
mux := http.NewServeMux()
static, err := fs.Sub(frontend, "frontend")
if err != nil {
log.Fatal(err)
}
// Print the embedded filesystem tree
fmt.Println("Embedded frontend filesystem tree:")
err = printFSTree(static, ".", 0)
if err != nil {
log.Printf("Error printing filesystem tree: %v\n", err)
}
fmt.Println("--- End of tree ---")
addRoutes(
mux,
static,
nil, // templ
)
mux.HandleFunc("GET /stream", func(w http.ResponseWriter, r *http.Request) {
ticker := time.NewTicker(1000 * time.Millisecond)
// original: defer ticker.Stop()
defer func() {
fmt.Println("defer executed")
ticker.Stop()
}()
sse := datastar.NewSSE(w, r)
for {
select {
case <-r.Context().Done():
slog.Debug("Client connection closed")
return
case <-ticker.C:
bytes := make([]byte, 3)
if _, err := rand.Read(bytes); err != nil {
slog.Error("Error generating random bytes: ", slog.String("error", err.Error()))
return
}
hexString := hex.EncodeToString(bytes)
frag := fmt.Sprintf(`<span id="feed" style="color:#%s;border:1px solid #%s;border-radius:0.25rem;padding:1rem;">%s</span>`, hexString, hexString, hexString)
sse.MergeFragments(frag)
}
}
})
var handler http.Handler = mux
// handler = authMiddleware(handler)
handler = headerMiddleware(handler)
return handler
}
// printFSTree prints a tree-like structure of the given filesystem.
func printFSTree(efs fs.FS, root string, indentLevel int) error {
return fs.WalkDir(efs, root, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
// Skip the root directory itself for cleaner output if it's "."
if path == "." && root == "." {
return nil
}
indent := strings.Repeat("│ ", indentLevel)
connector := "├── "
// For the last item in a directory, use a different connector.
// This requires knowing if it's the last item, which fs.WalkDir doesn't directly provide.
// For simplicity, we'll use the same connector for all items.
// A more sophisticated approach would involve reading directory entries first.
fmt.Printf("%s%s%s\n", indent, connector, filepath.Base(path))
if d.IsDir() && path != root { // Avoid infinite recursion for the root itself if not handled carefully
// The WalkDir function handles recursion, so we don't need to call printFSTree recursively here.
// We adjust indentLevel based on path depth for visual representation.
// This simple indentation based on WalkDir's path might not be perfect for deep structures
// but gives a good overview.
}
return nil
})
}
// authMiddleware is a simple authentication middleware
// func authMiddleware(next http.Handler) http.Handler {
// return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// user, pass, ok := r.BasicAuth()
// if !ok || !validateUser(user, pass) {
// w.Header().Set("WWW-Authenticate", `Basic realm="Please enter your credentials"`)
// http.Error(w, "Unauthorized", http.StatusUnauthorized)
// return
// }
// next.ServeHTTP(w, r)
// })
// }
// authMiddleware is a simple authentication middleware
func headerMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Println("Request URL:", r.URL.String())
fmt.Println("Request Headers:")
for key, values := range r.Header {
for _, value := range values {
if key == "Referer" || strings.HasPrefix(key, "Hx") {
fmt.Printf("%s: %s\n", key, value)
}
}
}
fmt.Println()
next.ServeHTTP(w, r)
})
}
// validateUser validates the user credentials
func validateUser(username, password string) bool {
// In a real application, these credentials should be stored securely.
return strings.EqualFold(username, "admin") && password == "password"
}

3
mvac/readme.md Normal file
View File

@ -0,0 +1,3 @@
# History
this is going to be an upgrade to the latest datastar version

25
mvac/routes.go Normal file
View File

@ -0,0 +1,25 @@
package main
// This file is the one place in your application where all routes are listed.
import (
"html/template"
"io/fs"
"net/http"
)
// addRoutes combines the URL endpoints with the applications's services
// and dependencies and required middleware
func addRoutes(
mux *http.ServeMux,
// database *sqlite.Database,
static fs.FS,
templ *template.Template,
) {
mux.Handle("GET /", http.FileServer(http.FS(static)))
// mux.Handle("GET /tables", api.TableList(database))
// mux.Handle("GET /count", api.ProductCount(database))
// mux.Handle("GET /nutriments/{page}", api.DataNutriments(database, templ))
// mux.Handle("GET /products/{page}", api.DataProducts(database, templ))
// mux.Handle("GET /brandowner/{page}", api.DataBrandOwner(database, templ))
}

614
mvac/sqlite/database.go Normal file
View File

@ -0,0 +1,614 @@
package sqlite // name the package as you see fit, it is intended to be vendored
import (
"bytes"
"database/sql"
"errors"
"context"
"fmt"
"os"
"strconv"
"text/template"
_ "modernc.org/sqlite"
)
/*
Package sqlite provides a simplified wrapper around the modernc.org/sqlite driver.
It aims to provide a convenient, developer-friendly interface for common database
operations, prioritizing ease of use with a map-based data exchange format (Record).
Key Concepts:
- Database Instance: A single `Database` struct instance manages the connection to
a specific database file or an in-memory database.
- Lifecycle: Use `New()` to create an instance, `Open()` or `OpenInMemory()`
to establish the connection, and `defer Close()` to release resources.
- Record Type: `type Record = map[string]any` is the primary type for exchanging
data with the database. Column names become map keys.
- Underlying DB Access: The `DB()` method provides access to the raw `*sql.DB`
object for operations not covered by the wrapper.
Features:
- Reading Data:
- `ReadTable(tablename string)`: Reads all rows and columns from a specified table.
- `ReadRecords(query string, args ...any)`: Executes a custom SQL SELECT query
with parameterized arguments and returns multiple records.
- `GetRecord(tablename string, idfield string, key any)`: Retrieves a single
record from a table based on a unique identifier.
- Writing Data:
- `UpsertRecord(tablename string, idfield string, record Record)`: Inserts a new
record or updates an existing one based on the value of the `idfield`.
Uses SQLite's `ON CONFLICT` clause.
- Supports partial updates: Only include fields you want to insert/update in the `Record`.
- Returns the full resulting record (including auto-generated IDs) using `RETURNING *`.
- Deleting Data:
- `DeleteRecord(tablename string, idfield string, id any)`: Deletes a single
record from a table based on its identifier.
- Metadata:
- `TableList()`: Lists all tables in the database.
- `Version()`: Gets the SQLite library version.
- `UserVersion()`: Gets the database's user_version PRAGMA.
Transaction Handling:
- `Begin()`: Starts a new database transaction, returning a `*Transaction` object.
- Chaining: Transaction methods (`GetRecord`, `UpsertRecord`, `DeleteRecord`, `Next`)
return the `*Transaction` object, allowing operations to be chained.
- Error Propagation: If any operation within a transaction chain fails, the error
is stored in the `Transaction` object (`tx.Err()`), and subsequent chained
operations become no-ops.
- `Next(action Action)`: Allows executing custom logic within the transaction
by providing a function that receives the raw `*sql.Tx`.
- `End()`: Finalizes the transaction. If `tx.Err()` is non-nil, it performs a
ROLLBACK; otherwise, it performs a COMMIT. Returns the accumulated error.
Helper Functions:
- `ValueT any`: A generic helper to safely extract
and type-assert a value from a `Record` map.
- `NoRowsOk([]Record, error)`: A helper to wrap calls that might return
`sql.ErrNoRows` and treat that specific error as a non-error case, returning
nil records and a nil error.
Prerequisites:
- For `UpsertRecord` to function correctly, the target table must have a unique
index defined on the specified `idfield`.
- It is highly recommended that the `idfield` is an `INTEGER PRIMARY KEY AUTOINCREMENT`
to leverage SQLite's built-in ID generation and efficient lookups.
Shortcomings and Important Considerations:
- SQL Injection Risk:
- Identifiers: Table names, field names, and record keys (used as field names)
are validated to contain only alphanumeric characters and underscores. They are
also quoted by the library. This significantly mitigates SQL injection risks
through identifiers. However, the caller MUST still ensure that these identifiers
refer to the *intended* database objects.
- Query Structure: For `ReadRecords` and `Transaction.Next` actions, if the raw
SQL query string itself is constructed from untrusted user input, it remains a
potential SQL injection vector. Parameterization is used by this library (and
`database/sql`) only for *values*, not for the query structure or identifiers
within a user-provided query string.
- Simplicity over Edge Cases: This is a simplified layer. More complex scenarios
or advanced SQLite features might require using the underlying `*sql.DB` object
via the `DB()` method.
- Room for Improvement: As a fresh implementation, there is potential for
further optimization and refinement.
Implementation Details:
- Uses the `modernc.org/sqlite` driver.
- SQL commands for `UpsertRecord` are dynamically generated using Go's `text/template`.
- Internal interfaces (`iquery`, `iExec`) are used to allow functions like `upsert`
and `deleteRecord` to work seamlessly with both `*sql.DB` and `*sql.Tx`.
Unit Tests:
- The package includes unit tests (`database_test.go`, `transaction_test.go`, `helpers_test.go`)
covering core functionality and transaction handling.
*/
// ErrInvalidIdentifier is returned when a table or column name contains disallowed characters.
var ErrInvalidIdentifier = errors.New("invalid identifier: contains disallowed characters")
// This is the data type to exchange data with the database
type Record = map[string]any
type Database struct {
databaseName string
database *sql.DB
}
func New(DBName string) *Database {
return &Database{databaseName: DBName}
}
func (d *Database) Close() error {
return d.database.Close()
}
// provides access to the internal database object
func (d *Database) DB() *sql.DB {
return d.database
}
func (d *Database) Name() string {
return d.databaseName
}
// basePragmas returns a string of common PRAGMA settings for SQLite.
// It excludes user_version, which is typically managed by schema migrations.
func basePragmas() string {
return `
PRAGMA page_size = 4096;
PRAGMA synchronous = NORMAL;
PRAGMA foreign_keys = ON;
PRAGMA journal_mode = WAL;
`
}
func (d *Database) Open(ctx context.Context) (err error) {
d.database, err = openSqliteDB(ctx, d.databaseName)
return err
}
func (d *Database) OpenInMemory(ctx context.Context) (err error) {
db, err := sql.Open("sqlite", ":memory:")
if err != nil {
return err
}
// Apply base PRAGMAs for consistency in in-memory databases.
_, err = db.ExecContext(ctx, basePragmas())
d.database = db
return err
}
func openSqliteDB(ctx context.Context, databasefilename string) (*sql.DB, error) {
_, err := os.Stat(databasefilename)
if errors.Is(err, os.ErrNotExist) {
return createDB(ctx, databasefilename)
}
if err != nil {
return nil, err
}
return sql.Open("sqlite", databasefilename)
}
func createDB(ctx context.Context, dbfileName string) (*sql.DB, error) {
// Apply base pragmas and set initial user_version for new database files.
query := basePragmas() + "PRAGMA user_version = 1;\n"
db, err := sql.Open("sqlite", dbfileName)
if err != nil {
return nil, err
}
_, err = db.ExecContext(ctx, query)
if err != nil {
db.Close() // Best effort to close if ExecContext fails
os.Remove(dbfileName) // Best effort to remove partially created file
return nil, err
}
return db, nil
}
func (d *Database) TableList(ctx context.Context) (result []Record, err error) {
return d.ReadRecords(ctx, "select name from sqlite_master where type='table';")
}
func (d *Database) ReadTable(ctx context.Context, tablename string) (result []Record, err error) {
if !isValidIdentifier(tablename) {
return nil, fmt.Errorf("ReadTable: %w: table name '%s'", ErrInvalidIdentifier, tablename)
}
return d.ReadRecords(ctx, fmt.Sprintf("select * from \"%s\";", tablename)) // Use double quotes for identifiers
}
func (d *Database) ReadRecords(ctx context.Context, query string, args ...any) (result []Record, err error) {
// Note: For ReadRecords, the query string itself is provided by the caller.
// The library cannot validate the structure of this query beyond what the driver does.
// The SQL injection caveat for arbitrary query strings remains critical here.
rows, err := d.DB().QueryContext(ctx, query, args...)
if err != nil {
return result, err
}
defer rows.Close()
return Rows2records(rows)
}
func (d *Database) GetRecord(ctx context.Context, tablename string, idfield string, key any) (result Record, err error) {
if !isValidIdentifier(tablename) {
return nil, fmt.Errorf("GetRecord: %w: table name '%s'", ErrInvalidIdentifier, tablename)
}
if !isValidIdentifier(idfield) {
return nil, fmt.Errorf("GetRecord: %w: id field '%s'", ErrInvalidIdentifier, idfield)
}
query := fmt.Sprintf("select * from \"%s\" where \"%s\" = ?;", tablename, idfield) // Quote identifiers
res, err := d.DB().QueryContext(ctx, query, key)
if err != nil {
return result, err
}
defer res.Close()
return Rows2record(res)
}
func (d *Database) UpsertRecord(ctx context.Context, tablename string, idfield string, record Record) (result Record, err error) {
if !isValidIdentifier(tablename) {
return nil, fmt.Errorf("UpsertRecord: %w: table name '%s'", ErrInvalidIdentifier, tablename)
}
if !isValidIdentifier(idfield) {
return nil, fmt.Errorf("UpsertRecord: %w: id field '%s'", ErrInvalidIdentifier, idfield)
}
return upsert(ctx, d.DB(), tablename, idfield, record)
}
func (d *Database) DeleteRecord(ctx context.Context, tablename string, idfield string, id any) (err error) {
// Validation for tablename and idfield will be done by deleteRecord internal helper
// to ensure consistency for both Database and Transaction calls.
return deleteRecord(ctx, d.DB(), tablename, idfield, id)
}
// *sql.DB and *sql.Tx both have a method named 'Query',
// this way they can both be passed into upsert and deleteRecord function
type iqueryContext interface {
QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error)
}
// iExec is an interface satisfied by both *sql.DB and *sql.Tx for Exec method
type iExecContext interface {
ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error)
}
func upsert(ctx context.Context, q iqueryContext, tablename string, idfield string, record Record) (result Record, err error) {
// tablename and idfield are assumed to be validated by the public-facing methods (Database.UpsertRecord, Transaction.UpsertRecord)
fields := []string{}
data := []any{}
for k, v := range record {
if !isValidIdentifier(k) {
return nil, fmt.Errorf("upsert: %w: field name '%s'", ErrInvalidIdentifier, k)
}
fields = append(fields, k)
data = append(data, v)
}
// Ensure idfield is part of the record if it's used for conflict and update,
// or handle cases where it might only be for conflict target and not in SET.
// The current buildUpsertCommand uses all fields from the record for the SET clause.
if _, present := record[idfield]; !present && len(record) > 0 {
// This situation is complex: if idfield is not in the record,
// it implies it might be auto-generated on INSERT, but for UPDATE,
// it's needed to identify the row. The ON CONFLICT target uses idfield.
// The current template includes all record fields in the SET clause.
// If idfield is not in record, it won't be in the SET clause unless explicitly added.
// For simplicity and current template, we assume if idfield is for update, it should be in the record.
}
if len(fields) == 0 {
return nil, errors.New("UpsertRecord: input record cannot be empty")
}
query, err := buildUpsertCommand(tablename, idfield, fields)
if err != nil {
return result, err
}
res, err := q.QueryContext(ctx, query, data...) // res contains the full record - see SQLite: RETURNING *
if err != nil {
return result, err
}
defer res.Close()
return Rows2record(res)
}
func deleteRecord(ctx context.Context, e iExecContext, tablename string, idfield string, id any) (err error) {
if !isValidIdentifier(tablename) {
return fmt.Errorf("deleteRecord: %w: table name '%s'", ErrInvalidIdentifier, tablename)
}
if !isValidIdentifier(idfield) {
return fmt.Errorf("deleteRecord: %w: id field '%s'", ErrInvalidIdentifier, idfield)
}
query := fmt.Sprintf("DELETE FROM \"%s\" WHERE \"%s\" = ?;", tablename, idfield)
_, err = e.ExecContext(ctx, query, id)
// Note: err could be sql.ErrNoRows if the driver/db supports it for Exec,
// or nil if delete affected 0 rows. Caller might want to check result.RowsAffected().
// For simplicity here, we just return the error from Exec.
return err
}
func buildUpsertCommand(tablename string, idfield string, fields []string) (result string, err error) {
// Assumes tablename, idfield, and all elements in fields are already validated
// by the calling function (e.g., upsert).
// And that fields is not empty.
pname := map[string]string{} // assign correct index for parameter name
// parameter position, starts at 1 in sql! So it needs to be calculated by function pname inside template
for i, k := range fields {
pname[k] = strconv.Itoa(i + 1)
}
funcMap := template.FuncMap{
"pname": func(fieldname string) string {
return pname[fieldname]
},
}
tableDef := struct {
Tablename string
KeyField string
LastField int
FieldNames []string
}{
Tablename: tablename,
KeyField: idfield,
LastField: len(fields) - 1,
FieldNames: fields,
}
var templString = `{{$last := .LastField}}INSERT INTO "{{ .Tablename }}"({{ range $i,$el := .FieldNames }} "{{$el}}"{{if ne $i $last}},{{end}}{{end}})
VALUES({{ range $i,$el := .FieldNames }} ?{{pname $el}}{{if ne $i $last}},{{end}}{{end}})
ON CONFLICT("{{ .Tablename }}"."{{.KeyField}}")
DO UPDATE SET {{ range $i,$el := .FieldNames }}"{{$el}}"= ?{{pname $el}}{{if ne $i $last}},{{end}}{{end}}
RETURNING *;`
dbTempl, err := template.New("upsertDB").Funcs(funcMap).Parse(templString)
if err != nil {
return result, err
}
var templBytes bytes.Buffer
err = dbTempl.Execute(&templBytes, tableDef)
if err != nil {
return result, err
}
return templBytes.String(), nil
}
func Rows2record(rows *sql.Rows) (Record, error) {
columns, err := rows.Columns()
if err != nil {
return nil, err
}
values := make([]any, len(columns))
valuePtrs := make([]any, len(columns))
for i := range values {
valuePtrs[i] = &values[i]
}
result := Record{}
if !rows.Next() {
if err := rows.Err(); err != nil { // Check for errors during iteration attempt
return nil, err
}
return nil, sql.ErrNoRows // Standard error for no rows
}
if err := rows.Scan(valuePtrs...); err != nil {
return nil, err
}
for i, col := range columns {
result[col] = values[i]
}
// Check for errors encountered during iteration (e.g., if Next() was called multiple times).
if err := rows.Err(); err != nil {
return nil, err
}
return result, nil
}
func Rows2records(rows *sql.Rows) ([]Record, error) {
columns, err := rows.Columns()
if err != nil {
return nil, err
}
recLength := len(columns)
results := []Record{}
for rows.Next() {
valuePtrs := make([]any, recLength)
values := make([]any, recLength)
for i := range values {
valuePtrs[i] = &values[i]
}
record := Record{}
if err := rows.Scan(valuePtrs...); err != nil {
return nil, err
}
for i, col := range columns {
record[col] = values[i]
}
results = append(results, record)
}
// Check for errors encountered during iteration.
if err := rows.Err(); err != nil {
return nil, err
}
if len(results) == 0 {
// For a function returning a slice, an empty slice and nil error is often preferred for "no rows".
// However, if the expectation is that Rows2records is used where rows *should* exist, sql.ErrNoRows is appropriate.
return nil, sql.ErrNoRows // Or: return []Record{}, nil if empty slice is the desired "no rows" outcome
}
return results, nil
}
func (d *Database) Version(ctx context.Context) (string, error) {
var version string
err := d.DB().QueryRowContext(ctx, "SELECT sqlite_version();").Scan(&version)
return version, err
}
func (d *Database) UserVersion(ctx context.Context) (int64, error) {
var result int64
// PRAGMA user_version; returns a single row with a single column named "user_version".
// QueryRow().Scan() is appropriate here.
err := d.DB().QueryRowContext(ctx, "PRAGMA user_version;").Scan(&result)
return result, err
}
func (d *Database) BeginTx(ctx context.Context, opts *sql.TxOptions) *Transaction {
tx, err := d.database.BeginTx(ctx, opts)
return &Transaction{tx, err}
}
type Transaction struct {
tx *sql.Tx
err error
}
// Err returns the current error state of the transaction.
func (t *Transaction) Err() error {
return t.err
}
type Action func(ctx context.Context, tx *sql.Tx) error
func (t *Transaction) Next(ctx context.Context, action Action) *Transaction {
if t.err != nil {
return t
}
t.err = action(ctx, t.tx)
return t
}
func (t *Transaction) End() error {
if t.tx == nil { // Transaction was never begun or already ended
return t.err // Return any prior error
}
if t.err != nil {
err := t.tx.Rollback() // Rollback does not take context
if err != nil {
t.err = errors.Join(t.err, err)
}
return t.err
}
t.err = t.tx.Commit()
return t.err
}
func (t *Transaction) GetRecord(ctx context.Context, tablename string, idfield string, key any, output Record) *Transaction {
if !isValidIdentifier(tablename) {
t.err = fmt.Errorf("Transaction.GetRecord: %w: table name '%s'", ErrInvalidIdentifier, tablename)
return t
}
if !isValidIdentifier(idfield) {
t.err = fmt.Errorf("Transaction.GetRecord: %w: id field '%s'", ErrInvalidIdentifier, idfield)
return t
}
if t.err != nil {
return t
}
query := fmt.Sprintf("select * from \"%s\" where \"%s\" = ?;", tablename, idfield) // Quote identifiers
res, err := t.tx.QueryContext(ctx, query, key)
if err != nil {
t.err = err
return t
}
defer res.Close()
result, err := Rows2record(res)
if err != nil {
t.err = err
return t
}
for k := range output {
delete(output, k)
}
for k, v := range result {
output[k] = v
}
return t
}
func (t *Transaction) UpsertRecord(ctx context.Context, tablename string, idfield string, record Record, output Record) *Transaction {
if !isValidIdentifier(tablename) {
t.err = fmt.Errorf("Transaction.UpsertRecord: %w: table name '%s'", ErrInvalidIdentifier, tablename)
return t
}
if !isValidIdentifier(idfield) {
t.err = fmt.Errorf("Transaction.UpsertRecord: %w: id field '%s'", ErrInvalidIdentifier, idfield)
return t
}
if t.err != nil {
return t
}
result, err := upsert(ctx, t.tx, tablename, idfield, record)
if err != nil {
t.err = err
return t
}
for k := range output {
delete(output, k)
}
for k, v := range result {
output[k] = v
}
return t
}
func (t *Transaction) DeleteRecord(ctx context.Context, tablename string, idfield string, id any) *Transaction {
// Validation will be done by the internal deleteRecord helper
// if !isValidIdentifier(tablename) {
// t.err = fmt.Errorf("Transaction.DeleteRecord: %w: table name '%s'", ErrInvalidIdentifier, tablename)
// return t
// }
// if !isValidIdentifier(idfield) {
// t.err = fmt.Errorf("Transaction.DeleteRecord: %w: id field '%s'", ErrInvalidIdentifier, idfield)
// return t
// }
if t.err != nil {
return t
}
err := deleteRecord(ctx, t.tx, tablename, idfield, id) // t.tx satisfies iExecContext
if err != nil {
t.err = err
}
return t
}
// returns a value of the provided type, if the field exist and if it can be cast into the provided type parameter
func Value[T any](rec Record, field string) (value T, ok bool) {
var v any
// No validation for 'field' here as it's used to access a map key from an existing Record,
// not to construct SQL.
if v, ok = rec[field]; ok {
value, ok = v.(T)
}
return
}
// don't report an error if there are simply just 'no rows found'
func NoRowsOk(recs []Record, err error) ([]Record, error) {
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
// Return an empty, non-nil slice and nil error to indicate "no rows found, but that's okay".
// This makes it safer for callers to immediately use len() or range over the result.
return []Record{}, nil
}
return recs, err
}
return recs, nil
}
// isValidIdentifier checks if the given string is a safe identifier.
// Allows alphanumeric characters and underscores. Must not be empty.
func isValidIdentifier(identifier string) bool {
if len(identifier) == 0 {
return false
}
for _, r := range identifier {
if !((r >= 'a' && r <= 'z') ||
(r >= 'A' && r <= 'Z') ||
(r >= '0' && r <= '9') ||
r == '_') {
return false
}
}
return true
}