initial commit
Some checks failed
ci / go (push) Has been cancelled
ci / admin-ui (push) Has been cancelled
ci / runtime-sdk (push) Has been cancelled
ci / helm (push) Has been cancelled
ci / openapi (push) Has been cancelled
ci / container (push) Has been cancelled
ci / release-config (push) Has been cancelled
ci / shared styling (push) Has been cancelled
ci / security (push) Has been cancelled
ci / all green (push) Has been cancelled
Some checks failed
ci / go (push) Has been cancelled
ci / admin-ui (push) Has been cancelled
ci / runtime-sdk (push) Has been cancelled
ci / helm (push) Has been cancelled
ci / openapi (push) Has been cancelled
ci / container (push) Has been cancelled
ci / release-config (push) Has been cancelled
ci / shared styling (push) Has been cancelled
ci / security (push) Has been cancelled
ci / all green (push) Has been cancelled
This commit is contained in:
commit
951dd8d26b
175 changed files with 50041 additions and 0 deletions
761
internal/httpapi/runtime.go
Normal file
761
internal/httpapi/runtime.go
Normal file
|
|
@ -0,0 +1,761 @@
|
|||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"html"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/urpi/go-bananas/internal/config"
|
||||
"github.com/urpi/go-bananas/internal/domain"
|
||||
"github.com/urpi/go-bananas/internal/store"
|
||||
"github.com/urpi/go-bananas/internal/tokenengine"
|
||||
)
|
||||
|
||||
// RuntimeHandlers serves the public, cacheable theming endpoints.
|
||||
//
|
||||
// Everything here is designed around one fact: the most common consumer is a
|
||||
// browser following a <link rel="stylesheet"> tag, often on a page the developer
|
||||
// has never seen go-bananas before. That means the URLs must be predictable, the
|
||||
// responses must be immutable-by-URL so a CDN can cache them indefinitely, and a
|
||||
// request must never require a credential.
|
||||
type RuntimeHandlers struct {
|
||||
store *store.Store
|
||||
cache *ThemeCache
|
||||
config config.Config
|
||||
log Logger
|
||||
// defaults is the manifest default, resolved from instance settings.
|
||||
defaults func() (slug string, appearance domain.Appearance)
|
||||
}
|
||||
|
||||
// NewRuntimeHandlers builds the runtime handler set.
|
||||
func NewRuntimeHandlers(
|
||||
store *store.Store,
|
||||
cache *ThemeCache,
|
||||
cfg config.Config,
|
||||
log Logger,
|
||||
defaults func() (string, domain.Appearance),
|
||||
) *RuntimeHandlers {
|
||||
return &RuntimeHandlers{
|
||||
store: store,
|
||||
cache: cache,
|
||||
config: cfg,
|
||||
log: log,
|
||||
defaults: defaults,
|
||||
}
|
||||
}
|
||||
|
||||
// ThemeCSS serves a theme's stylesheet.
|
||||
//
|
||||
// This is the endpoint the product exists for. It answers with immutable-by-URL
|
||||
// caching: the URL contains the theme slug and its version, so a publish changes
|
||||
// the URL and every cached copy becomes unreachable rather than stale.
|
||||
func (h *RuntimeHandlers) ThemeCSS(w http.ResponseWriter, r *http.Request) {
|
||||
slug := strings.TrimSpace(r.URL.Query().Get("slug"))
|
||||
if slug == "" {
|
||||
slug = themeSlugFromPath(r)
|
||||
}
|
||||
if slug == "" {
|
||||
writeError(w, r, http.StatusBadRequest, CodeBadRequest,
|
||||
"a theme slug is required, either as ?slug= or as a path segment", nil)
|
||||
return
|
||||
}
|
||||
|
||||
// An absent appearance is left empty rather than defaulted to light, so that
|
||||
// the renderer falls back to the theme's own default. Hard-coding light here
|
||||
// would silently serve a dark-first theme's light scheme to every consumer
|
||||
// that did not pass the parameter.
|
||||
appearance, err := parseOptionalAppearance(r.URL.Query().Get("appearance"))
|
||||
if err != nil {
|
||||
writeError(w, r, http.StatusBadRequest, CodeBadRequest, err.Error(), nil)
|
||||
return
|
||||
}
|
||||
|
||||
variant := strings.TrimSpace(r.URL.Query().Get("variant"))
|
||||
if variant == "" {
|
||||
variant = tokenengine.VariantFull
|
||||
}
|
||||
if !validVariant(variant) {
|
||||
writeError(w, r, http.StatusBadRequest, CodeBadRequest,
|
||||
"unknown variant "+variant+"; expected one of "+strings.Join(tokenengine.VariantNames(), ", "), nil)
|
||||
return
|
||||
}
|
||||
|
||||
// A pinned version lets a consumer resolve a specific published revision
|
||||
// even after newer ones ship, which is what makes a safe deploy possible.
|
||||
pinned := int64(0)
|
||||
if raw := strings.TrimSpace(r.URL.Query().Get("v")); raw != "" {
|
||||
parsed, convErr := strconv.ParseInt(raw, 10, 64)
|
||||
if convErr != nil || parsed < 1 {
|
||||
writeError(w, r, http.StatusBadRequest, CodeBadRequest, "v must be a positive integer", nil)
|
||||
return
|
||||
}
|
||||
pinned = parsed
|
||||
}
|
||||
|
||||
theme, err := h.resolve(r.Context(), slug, pinned)
|
||||
if err != nil {
|
||||
writeProblem(w, r, err)
|
||||
return
|
||||
}
|
||||
if theme.Status == domain.StatusDraft {
|
||||
// A draft is not part of the runtime contract. Returning 404 rather than
|
||||
// 403 avoids confirming to a prober that the slug exists.
|
||||
writeError(w, r, http.StatusNotFound, CodeNotFound, "no such published theme", nil)
|
||||
return
|
||||
}
|
||||
|
||||
opts := tokenengine.OptionsForVariant(variant, theme.Slug, appearance)
|
||||
opts.SourceURL = h.sourceURL(r, theme, variant, pinned)
|
||||
|
||||
version := theme.Version
|
||||
if pinned > 0 {
|
||||
version = pinned
|
||||
}
|
||||
|
||||
css, etag := h.cache.CSS(theme, opts, func() string {
|
||||
return tokenengine.Render(theme, opts)
|
||||
})
|
||||
|
||||
h.writeCacheable(w, r, []byte(css), "text/css; charset=utf-8", etag, version, theme.UpdatedAt)
|
||||
}
|
||||
|
||||
// ContractCSS serves the shared token contract for every published theme at once.
|
||||
//
|
||||
// It exists because a theme selector that has to fetch a stylesheet per palette
|
||||
// flashes on every switch, and because a consumer wanting one theme should not
|
||||
// download the other five. So they are two documents:
|
||||
//
|
||||
// /rt/v1/contract.css every palette, both appearances. For the selector.
|
||||
// /rt/v1/theme.css one theme in full. For a single-theme consumer.
|
||||
//
|
||||
// With contract.css in place, choosing a palette is a change to one attribute on the
|
||||
// root element and costs no request at all.
|
||||
func (h *RuntimeHandlers) ContractCSS(w http.ResponseWriter, r *http.Request) {
|
||||
themes, err := h.store.ListRuntime(r.Context())
|
||||
if err != nil {
|
||||
writeProblem(w, r, err)
|
||||
return
|
||||
}
|
||||
if len(themes) == 0 {
|
||||
writeError(w, r, http.StatusNotFound, CodeNotFound, "no themes have been published", nil)
|
||||
return
|
||||
}
|
||||
|
||||
body := tokenengine.ContractAll(themes)
|
||||
header := w.Header()
|
||||
header.Set("Content-Type", "text/css; charset=utf-8")
|
||||
header.Set("Cache-Control", h.config.RuntimeCacheControl)
|
||||
header.Set("Vary", "Accept-Encoding")
|
||||
// The contract changes only when a theme is published, so the aggregate version
|
||||
// is what a cache should revalidate against.
|
||||
header.Set("X-Theme-Version", strconv.Itoa(len(themes)))
|
||||
_, _ = w.Write([]byte(body))
|
||||
}
|
||||
|
||||
// ContractJSON lists the palettes and appearances a selector can offer, so a consumer
|
||||
// does not have to parse CSS to populate a menu.
|
||||
func (h *RuntimeHandlers) ContractJSON(w http.ResponseWriter, r *http.Request) {
|
||||
themes, err := h.store.ListRuntime(r.Context())
|
||||
if err != nil {
|
||||
writeProblem(w, r, err)
|
||||
return
|
||||
}
|
||||
if len(themes) == 0 {
|
||||
writeError(w, r, http.StatusNotFound, CodeNotFound, "no themes have been published", nil)
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, r, http.StatusOK, map[string]any{
|
||||
"themes": tokenengine.ContractManifest(themes),
|
||||
"modes": []map[string]string{
|
||||
{"value": string(domain.AppearanceLight), "label": "Light"},
|
||||
{"value": string(domain.AppearanceDark), "label": "Dark"},
|
||||
},
|
||||
// The attributes a consumer sets. Emitted rather than hard-coded in the SDK,
|
||||
// because a host application may already have committed to different ones.
|
||||
"themeAttribute": tokenengine.ContractThemeAttribute,
|
||||
"modeAttribute": tokenengine.ContractModeAttribute,
|
||||
"legacyThemeAttribute": tokenengine.ThemeAttribute,
|
||||
"legacyModeAttribute": tokenengine.AppearanceAttribute,
|
||||
"variablePrefix": tokenengine.VariablePrefix,
|
||||
"stylesheet": h.config.CDNBasis() + "/contract.css",
|
||||
})
|
||||
}
|
||||
|
||||
// ThemeJSON serves a theme's resolved tokens as JSON.
|
||||
//
|
||||
// The payload carries both a flat custom-property map and the nested token
|
||||
// document, so a consumer can read either without a transformation step.
|
||||
func (h *RuntimeHandlers) ThemeJSON(w http.ResponseWriter, r *http.Request) {
|
||||
slug := strings.TrimSpace(r.URL.Query().Get("slug"))
|
||||
if slug == "" {
|
||||
slug = themeSlugFromPath(r)
|
||||
}
|
||||
if slug == "" {
|
||||
writeError(w, r, http.StatusBadRequest, CodeBadRequest, "a theme slug is required", nil)
|
||||
return
|
||||
}
|
||||
|
||||
appearance, err := parseOptionalAppearance(r.URL.Query().Get("appearance"))
|
||||
if err != nil {
|
||||
writeError(w, r, http.StatusBadRequest, CodeBadRequest, err.Error(), nil)
|
||||
return
|
||||
}
|
||||
|
||||
theme, err := h.store.Get(r.Context(), slug)
|
||||
if err != nil {
|
||||
writeProblem(w, r, err)
|
||||
return
|
||||
}
|
||||
if theme.Status == domain.StatusDraft {
|
||||
writeError(w, r, http.StatusNotFound, CodeNotFound, "no such published theme", nil)
|
||||
return
|
||||
}
|
||||
if appearance == "" {
|
||||
appearance = theme.DefaultAppearance
|
||||
}
|
||||
|
||||
payload, etag := h.cache.JSON(theme, appearance, func() tokenengine.ResolvedTheme {
|
||||
return tokenengine.JSONPayload(theme, appearance)
|
||||
})
|
||||
|
||||
// The JSON payload varies per appearance, so the ETag must too.
|
||||
etag = `"` + shortHash(etag+string(appearance)) + `"`
|
||||
h.writeCacheableJSON(w, r, payload, etag, theme.Version, theme.UpdatedAt)
|
||||
}
|
||||
|
||||
// Manifest serves the theme discovery document.
|
||||
//
|
||||
// A loader fetches this once at start-up, shows a theme picker, and then fetches
|
||||
// only the CSS for the chosen theme. It is deliberately small: no token data, so
|
||||
// that a page with a theme picker does not download every theme's variables.
|
||||
func (h *RuntimeHandlers) Manifest(w http.ResponseWriter, r *http.Request) {
|
||||
themes, err := h.store.ListRuntime(r.Context())
|
||||
if err != nil {
|
||||
writeProblem(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
// The fallback is applied at the point of use, because resolveDefaults returns
|
||||
// "" for a deployment with no default theme and assigning here produced an
|
||||
// ineffectual value that the linter was right to flag.
|
||||
defaultSlug, _ := h.resolveDefaults()
|
||||
|
||||
base := h.config.CDNBasis()
|
||||
manifest := tokenengine.Manifest{
|
||||
APIVersion: tokenengine.RuntimeAPIVersion,
|
||||
Default: defaultSlug,
|
||||
BaseURL: base,
|
||||
Themes: make([]tokenengine.ManifestEntry, 0, len(themes)),
|
||||
}
|
||||
|
||||
for _, theme := range themes {
|
||||
if theme.Status != domain.StatusPublished {
|
||||
continue
|
||||
}
|
||||
manifest.Themes = append(manifest.Themes, tokenengine.ManifestEntry{
|
||||
Slug: theme.Slug,
|
||||
Name: theme.Name,
|
||||
Description: theme.Description,
|
||||
DefaultAppearance: theme.DefaultAppearance,
|
||||
Version: theme.Version,
|
||||
Tags: tokenengine.Tags(theme.Meta),
|
||||
CSS: base + "/theme.css?slug=" + theme.Slug,
|
||||
Tokens: base + "/theme.json?slug=" + theme.Slug,
|
||||
})
|
||||
}
|
||||
|
||||
if len(manifest.Themes) == 0 {
|
||||
writeError(w, r, http.StatusNotFound, CodeNotFound, "no themes have been published", nil)
|
||||
return
|
||||
}
|
||||
|
||||
// The manifest changes whenever a theme is published, so it is revalidated
|
||||
// on every request rather than cached hard.
|
||||
w.Header().Set("Cache-Control", "public, max-age=30, stale-while-revalidate=120")
|
||||
writeJSON(w, r, http.StatusOK, manifest)
|
||||
}
|
||||
|
||||
// resolveDefaults returns the instance-level default theme and appearance.
|
||||
func (h *RuntimeHandlers) resolveDefaults() (string, domain.Appearance) {
|
||||
if h.defaults == nil {
|
||||
return "", ""
|
||||
}
|
||||
return h.defaults()
|
||||
}
|
||||
|
||||
// resolve loads a theme, optionally at a pinned version.
|
||||
func (h *RuntimeHandlers) resolve(c context.Context, slug string, pinned int64) (*domain.Theme, error) {
|
||||
if pinned == 0 {
|
||||
return h.store.Get(c, slug)
|
||||
}
|
||||
version, err := h.store.Version(c, slug, pinned)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Reconstruct a theme from the snapshot so a pinned request renders exactly
|
||||
// what that version rendered.
|
||||
return &domain.Theme{
|
||||
ID: version.ThemeID,
|
||||
Slug: version.Slug,
|
||||
Name: version.Name,
|
||||
Description: version.Description,
|
||||
Status: version.Status,
|
||||
DefaultAppearance: version.DefaultAppearance,
|
||||
Tokens: version.Tokens,
|
||||
Version: version.Version,
|
||||
UpdatedAt: version.CreatedAt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// writeCacheable emits a cacheable asset response, honouring conditional
|
||||
// requests.
|
||||
//
|
||||
// Answering 304 for an unchanged ETag is what keeps a re-deploy cheap for
|
||||
// consumers: the browser revalidates in a few milliseconds and transfers no body.
|
||||
func (h *RuntimeHandlers) writeCacheable(
|
||||
w http.ResponseWriter, r *http.Request,
|
||||
body []byte, contentType, etag string, version int64, updatedAt time.Time,
|
||||
) {
|
||||
header := w.Header()
|
||||
header.Set("Content-Type", contentType)
|
||||
header.Set("ETag", etag)
|
||||
header.Set("Cache-Control", h.config.RuntimeCacheControl)
|
||||
header.Set("Vary", "Accept-Encoding")
|
||||
header.Set("X-Theme-Version", strconv.FormatInt(version, 10))
|
||||
header.Set("X-Theme-Updated-At", updatedAt.UTC().Format(domain.TimeFormat))
|
||||
|
||||
// Revalidation: any of these headers may carry the client's cached copy.
|
||||
if etagMatches(r.Header.Get("If-None-Match"), etag) {
|
||||
w.WriteHeader(http.StatusNotModified)
|
||||
return
|
||||
}
|
||||
if modifiedSince := r.Header.Get("If-Modified-Since"); modifiedSince != "" {
|
||||
if since, err := http.ParseTime(modifiedSince); err == nil {
|
||||
if !updatedAt.UTC().Truncate(time.Second).After(since) {
|
||||
w.WriteHeader(http.StatusNotModified)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
if r.Method == http.MethodHead {
|
||||
return
|
||||
}
|
||||
_, _ = w.Write(body)
|
||||
}
|
||||
|
||||
// writeCacheableJSON emits a cacheable JSON response.
|
||||
func (h *RuntimeHandlers) writeCacheableJSON(
|
||||
w http.ResponseWriter, r *http.Request,
|
||||
payload tokenengine.ResolvedTheme, etag string, version int64, updatedAt time.Time,
|
||||
) {
|
||||
body, err := marshalJSON(payload)
|
||||
if err != nil {
|
||||
writeError(w, r, http.StatusInternalServerError, CodeInternal, "could not encode the theme payload", nil)
|
||||
return
|
||||
}
|
||||
|
||||
header := w.Header()
|
||||
header.Set("Content-Type", "application/json; charset=utf-8")
|
||||
header.Set("ETag", etag)
|
||||
header.Set("Cache-Control", h.config.RuntimeCacheControl)
|
||||
header.Set("X-Theme-Version", strconv.FormatInt(version, 10))
|
||||
header.Set("X-Theme-Updated-At", updatedAt.UTC().Format(domain.TimeFormat))
|
||||
|
||||
if etagMatches(r.Header.Get("If-None-Match"), etag) {
|
||||
w.WriteHeader(http.StatusNotModified)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
if r.Method == http.MethodHead {
|
||||
return
|
||||
}
|
||||
//nolint:gosec // G705: body is a marshalled JSON theme document.
|
||||
_, _ = w.Write(body)
|
||||
}
|
||||
|
||||
// etagMatches implements If-None-Match comparison, including the wildcard and
|
||||
// the weak-validator prefix.
|
||||
func etagMatches(header, etag string) bool {
|
||||
header = strings.TrimSpace(header)
|
||||
if header == "" {
|
||||
return false
|
||||
}
|
||||
if header == "*" {
|
||||
return true
|
||||
}
|
||||
for _, candidate := range strings.Split(header, ",") {
|
||||
candidate = strings.TrimSpace(candidate)
|
||||
candidate = strings.TrimPrefix(candidate, "W/")
|
||||
if candidate == etag {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// sourceURL builds the sourceURL comment embedded in a generated stylesheet so
|
||||
// that browser devtools can navigate back to the endpoint that produced it.
|
||||
func (h *RuntimeHandlers) sourceURL(r *http.Request, theme *domain.Theme, variant string, pinned int64) string {
|
||||
url := *r.URL
|
||||
query := url.Query()
|
||||
query.Set("slug", theme.Slug)
|
||||
query.Set("variant", variant)
|
||||
if pinned > 0 {
|
||||
query.Set("v", strconv.FormatInt(pinned, 10))
|
||||
}
|
||||
url.RawQuery = query.Encode()
|
||||
return url.RequestURI()
|
||||
}
|
||||
|
||||
// validVariant reports whether a variant name is known.
|
||||
func validVariant(variant string) bool {
|
||||
for _, known := range tokenengine.VariantNames() {
|
||||
if known == variant {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// parseOptionalAppearance validates an appearance parameter, allowing it to be
|
||||
// absent so that callers can distinguish "not specified" from "specified as
|
||||
// light".
|
||||
func parseOptionalAppearance(raw string) (domain.Appearance, error) {
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return "", nil
|
||||
}
|
||||
return domain.ParseAppearance(raw)
|
||||
}
|
||||
|
||||
// themeSlugFromPath extracts a slug from a path such as /rt/v1/themes/{slug}/theme.css.
|
||||
func themeSlugFromPath(r *http.Request) string {
|
||||
parts := strings.Split(strings.Trim(r.URL.Path, "/"), "/")
|
||||
for i, part := range parts {
|
||||
if part == "themes" && i+1 < len(parts) {
|
||||
return parts[i+1]
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// EmbedSnippet serves a ready-to-paste integration snippet for a theme.
|
||||
//
|
||||
// This exists because the single hardest moment for a developer adopting a runtime
|
||||
// theming service is the very first integration: knowing exactly what to paste.
|
||||
// Generating it from the deployment's own base URL means the snippet is correct by
|
||||
// construction.
|
||||
//
|
||||
// Three properties this endpoint has to hold, each of which was a bug first:
|
||||
//
|
||||
// - It always answers JSON, whatever the target. It used to answer text/html for
|
||||
// the default target, which meant the admin UI's fetch() could not parse the
|
||||
// response for the one target the UI defaults to.
|
||||
// - It validates the slug against the store. It used to echo whatever slug it was
|
||||
// given, so it would cheerfully generate a snippet for a theme that does not
|
||||
// exist.
|
||||
// - It escapes every interpolated value for the language of the target. The HTML
|
||||
// target escaped; the JavaScript and TSX targets did not, so a slug containing
|
||||
// a quote produced a snippet that broke out of its string literal. Since the
|
||||
// output is copy-pasted into someone's application, that was a code injection
|
||||
// into the consuming app, reachable unauthenticated.
|
||||
func (h *RuntimeHandlers) EmbedSnippet(w http.ResponseWriter, r *http.Request) {
|
||||
slug := strings.TrimSpace(r.URL.Query().Get("slug"))
|
||||
if slug == "" {
|
||||
slug = themeSlugFromPath(r)
|
||||
}
|
||||
if slug == "" {
|
||||
if defaultSlug, _ := h.resolveDefaults(); defaultSlug != "" {
|
||||
slug = defaultSlug
|
||||
}
|
||||
}
|
||||
if slug == "" {
|
||||
// No slug and no configured default: pick one that exists rather than
|
||||
// guessing a name, so the snippet is always about a real theme.
|
||||
themes, err := h.store.ListRuntime(r.Context())
|
||||
if err != nil || len(themes) == 0 {
|
||||
writeError(w, r, http.StatusNotFound, CodeNotFound,
|
||||
"no themes have been published, so there is nothing to generate a snippet for", nil)
|
||||
return
|
||||
}
|
||||
slug = themes[0].Slug
|
||||
}
|
||||
|
||||
// The existence check doubles as the injection guard's precondition: only a slug
|
||||
// that a theme actually owns can reach the template.
|
||||
if !isSlugShaped(slug) {
|
||||
writeError(w, r, http.StatusBadRequest, CodeBadRequest,
|
||||
"a theme slug may contain only lowercase letters, digits and single hyphens", nil)
|
||||
return
|
||||
}
|
||||
theme, err := h.store.Get(r.Context(), slug)
|
||||
if err != nil {
|
||||
writeError(w, r, http.StatusNotFound, CodeNotFound,
|
||||
"no theme named "+strconv.Quote(slug)+" exists", nil)
|
||||
return
|
||||
}
|
||||
|
||||
target := strings.TrimSpace(r.URL.Query().Get("target"))
|
||||
if target == "" {
|
||||
target = "html"
|
||||
}
|
||||
base := h.config.CDNBasis()
|
||||
|
||||
var payload map[string]any
|
||||
switch target {
|
||||
case "html":
|
||||
payload = map[string]any{
|
||||
"language": "html",
|
||||
"snippet": htmlIntegrationSnippet(base, slug),
|
||||
"note": "Paste into <head>. No build step and no npm dependency: the stylesheet is fetched from this deployment.",
|
||||
}
|
||||
case "react":
|
||||
payload = map[string]any{
|
||||
"language": "tsx",
|
||||
"snippet": reactIntegrationSnippet(base, slug),
|
||||
"note": "Add an import map entry mapping 'go-bananas/runtime' to " +
|
||||
jsString(base+"/react.js") + ", then wrap your tree in <BananasProvider>.",
|
||||
}
|
||||
case "javascript":
|
||||
payload = map[string]any{
|
||||
"language": "js",
|
||||
"snippet": javascriptIntegrationSnippet(base, slug),
|
||||
"note": "Import from the absolute URL, or add an import map entry for 'go-bananas/runtime'.",
|
||||
}
|
||||
default:
|
||||
// An unknown target used to fall through to the HTML branch, so a typo
|
||||
// silently returned a page instead of saying so.
|
||||
writeError(w, r, http.StatusBadRequest, CodeBadRequest,
|
||||
"target must be html, react or javascript", nil)
|
||||
return
|
||||
}
|
||||
|
||||
payload["slug"] = theme.Slug
|
||||
payload["name"] = theme.Name
|
||||
payload["version"] = theme.Version
|
||||
payload["baseUrl"] = base
|
||||
|
||||
// Snippets embed this deployment's absolute URL, so a CDN in front of it or a
|
||||
// different public hostname changes every snippet. Nothing here may be cached.
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
writeJSON(w, r, http.StatusOK, payload)
|
||||
}
|
||||
|
||||
// isSlugShaped reports whether a slug is made only of characters this service
|
||||
// accepts in one. It is a shape check, not an existence check: it exists so that a
|
||||
// crafted slug is rejected before it reaches a template, whatever the store would
|
||||
// have said about it.
|
||||
func isSlugShaped(slug string) bool {
|
||||
if slug == "" || len(slug) > 64 {
|
||||
return false
|
||||
}
|
||||
for i, r := range slug {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z':
|
||||
case r >= '0' && r <= '9':
|
||||
case r == '-' && i > 0 && i < len(slug)-1:
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// jsString quotes a value as a JavaScript single-quoted string literal, or returns
|
||||
// the empty string when there is no value.
|
||||
//
|
||||
// This is the guard the old endpoint was missing. Everything interpolated into a
|
||||
// JavaScript or TSX snippet goes through here, so no caller-supplied value can
|
||||
// terminate the literal it sits in.
|
||||
func jsString(s string) string {
|
||||
if s == "" {
|
||||
return ""
|
||||
}
|
||||
return "'" + strings.NewReplacer(
|
||||
`\`, `\\`,
|
||||
`'`, `\'`,
|
||||
"\n", `\n`,
|
||||
"\r", `\r`,
|
||||
"\t", `\t`,
|
||||
"<", `<`,
|
||||
">", `>`,
|
||||
"&", `&`,
|
||||
).Replace(s) + "'"
|
||||
}
|
||||
|
||||
// htmlIntegrationSnippet is the smallest useful integration: a stylesheet link and
|
||||
// one module script.
|
||||
func htmlIntegrationSnippet(base, slug string) string {
|
||||
return strings.Join([]string{
|
||||
`<link rel="stylesheet" href="` + htmlEscape(base) + `/theme.css?slug=` + htmlEscape(slug) + `">`,
|
||||
`<script type="module">`,
|
||||
` import { applyTheme, watchSystemAppearance } from ` + jsString(base+"/runtime.js") + `;`,
|
||||
` watchSystemAppearance();`,
|
||||
` applyTheme(` + jsString(slug) + `);`,
|
||||
`</script>`,
|
||||
}, "\n")
|
||||
}
|
||||
|
||||
// javascriptIntegrationSnippet is the same thing without the stylesheet link.
|
||||
func javascriptIntegrationSnippet(base, slug string) string {
|
||||
return strings.Join([]string{
|
||||
`import { applyTheme, watchSystemAppearance } from ` + jsString(base+"/runtime.js") + `;`,
|
||||
``,
|
||||
`applyTheme(` + jsString(slug) + `);`,
|
||||
`watchSystemAppearance();`,
|
||||
}, "\n")
|
||||
}
|
||||
|
||||
// reactIntegrationSnippet wires the provider component.
|
||||
func reactIntegrationSnippet(base, slug string) string {
|
||||
return strings.Join([]string{
|
||||
`<script type="importmap">`,
|
||||
`{ "imports": {`,
|
||||
` "react": "https://esm.sh/react@19",`,
|
||||
` "react-dom/client": "https://esm.sh/react-dom@19/client",`,
|
||||
` "go-bananas/runtime": "` + htmlEscape(base+"/react.js") + `"`,
|
||||
`} }`,
|
||||
`</script>`,
|
||||
`<script type="module">`,
|
||||
` import { BananasProvider } from 'go-bananas/runtime';`,
|
||||
` import { createRoot } from 'react-dom/client';`,
|
||||
` createRoot(document.getElementById('root')).render(`,
|
||||
` <BananasProvider slug=` + jsString(slug) + ` autoDark><App /></BananasProvider>`,
|
||||
` );`,
|
||||
`</script>`,
|
||||
}, "\n")
|
||||
}
|
||||
|
||||
// EmbedPage serves a standalone, shareable page that shows the snippet and renders
|
||||
// a live preview of the theme.
|
||||
//
|
||||
// It is separate from EmbedSnippet because the two have different callers: this one
|
||||
// is a page a human opens, the other is data the admin UI fetches. Having one serve
|
||||
// both is what produced the parse failure that motivated the split.
|
||||
//
|
||||
// Every code sample on the page comes from the same builders EmbedSnippet uses, so
|
||||
// what a developer copies here is byte-for-byte what the admin UI shows.
|
||||
func (h *RuntimeHandlers) EmbedPage(w http.ResponseWriter, r *http.Request) {
|
||||
slug := strings.TrimSpace(r.URL.Query().Get("slug"))
|
||||
if slug == "" {
|
||||
slug = themeSlugFromPath(r)
|
||||
}
|
||||
if slug == "" {
|
||||
if themes, err := h.store.ListRuntime(r.Context()); err == nil && len(themes) > 0 {
|
||||
slug = themes[0].Slug
|
||||
}
|
||||
}
|
||||
if slug == "" {
|
||||
writeError(w, r, http.StatusNotFound, CodeNotFound, "no themes have been published", nil)
|
||||
return
|
||||
}
|
||||
if !isSlugShaped(slug) {
|
||||
writeError(w, r, http.StatusBadRequest, CodeBadRequest, "invalid theme slug", nil)
|
||||
return
|
||||
}
|
||||
|
||||
base := h.config.CDNBasis()
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
//nolint:gosec // G705: embedPageHTML escapes the slug with htmlEscape at every
|
||||
// interpolation point, and the code samples it embeds come from the same builders
|
||||
// the JSON endpoint uses. gosec's taint analysis does not follow those calls.
|
||||
_, _ = w.Write([]byte(embedPageHTML(base, slug)))
|
||||
}
|
||||
|
||||
// embedPageHTML builds the standalone integration page.
|
||||
func embedPageHTML(base, slug string) string {
|
||||
samples := []struct {
|
||||
title string
|
||||
body string
|
||||
}{
|
||||
{"Any HTML page", htmlIntegrationSnippet(base, slug)},
|
||||
{"Plain JavaScript", javascriptIntegrationSnippet(base, slug)},
|
||||
{"React", reactIntegrationSnippet(base, slug)},
|
||||
}
|
||||
|
||||
sections := make([]string, 0, len(samples))
|
||||
for i, sample := range samples {
|
||||
sections = append(sections, ` <section>
|
||||
<h2>`+htmlEscape(sample.title)+`</h2>
|
||||
<pre><code>`+htmlEscape(sample.body)+`</code></pre>
|
||||
<button class="copy" data-index="`+strconv.Itoa(i)+`">Copy</button>
|
||||
</section>`)
|
||||
}
|
||||
|
||||
return `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="robots" content="noindex">
|
||||
<title>go-bananas · integrate ` + htmlEscape(slug) + `</title>
|
||||
<link rel="stylesheet" href="` + htmlEscape(base) + `/theme.css?slug=` + htmlEscape(slug) + `">
|
||||
<style>
|
||||
body { margin: 0; font-family: ui-sans-serif, system-ui, sans-serif; background: #0b0d12; color: #e6e8ef; }
|
||||
main { max-width: 60rem; margin: 0 auto; padding: 2.5rem 1.5rem 4rem; }
|
||||
h1 { font-size: 1.5rem; margin: 0 0 .25rem; letter-spacing: -0.02em; }
|
||||
p.lede { color: #9aa3b5; margin: 0 0 2rem; }
|
||||
section { margin-bottom: 2.5rem; }
|
||||
h2 { font-size: .8125rem; text-transform: uppercase; letter-spacing: .08em; color: #6b7488; margin: 0 0 .75rem; }
|
||||
pre { background: #11141c; border: 1px solid #232838; border-radius: .625rem; padding: 1rem; overflow-x: auto;
|
||||
font: 13px/1.65 ui-monospace, SFMono-Regular, Menlo, monospace; color: #cbd2e0; margin: 0; }
|
||||
button.copy { margin-top: .625rem; background: #1b2130; color: #cfd6e4; border: 1px solid #2a3145;
|
||||
border-radius: .5rem; padding: .4375rem .75rem; font: inherit; font-size: .8125rem; cursor: pointer; }
|
||||
button.copy:hover { background: #222a3c; }
|
||||
.preview { background: #0b0d12; border: 1px solid #232838; border-radius: .625rem; padding: 1.5rem; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>Integrate <code>` + htmlEscape(slug) + `</code></h1>
|
||||
<p class="lede">Copy one of these into your application. Nothing is installed: the
|
||||
theme is fetched from this deployment at run time.</p>
|
||||
|
||||
` + strings.Join(sections, "\n") + `
|
||||
|
||||
<section>
|
||||
<h2>Live preview</h2>
|
||||
<div class="preview" data-bananas-theme="` + htmlEscape(slug) + `">
|
||||
<button class="bn-btn">Primary action</button>
|
||||
<button class="bn-btn bn-btn--secondary">Secondary</button>
|
||||
<button class="bn-btn bn-btn--ghost">Ghost</button>
|
||||
<div class="bn-card" style="margin-top:1rem">
|
||||
<div class="bn-card__header">Card heading</div>
|
||||
<p class="bn-card__body">Every value here comes from a design token in the
|
||||
theme you are editing.</p>
|
||||
</div>
|
||||
<p style="margin-top:1rem"><span class="bn-badge bn-badge--success">published</span>
|
||||
<span class="bn-badge bn-badge--warning">draft</span>
|
||||
<span class="bn-badge bn-badge--danger">archived</span></p>
|
||||
<input class="bn-input" placeholder="Input field">
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
<script type="module">
|
||||
const codes = [...document.querySelectorAll('pre code')];
|
||||
for (const button of document.querySelectorAll('button.copy')) {
|
||||
button.addEventListener('click', async () => {
|
||||
await navigator.clipboard.writeText(codes[Number(button.dataset.index)]?.textContent ?? '');
|
||||
button.textContent = 'Copied';
|
||||
setTimeout(() => { button.textContent = 'Copy' }, 1200);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>`
|
||||
}
|
||||
|
||||
// htmlEscape escapes a string for interpolation into HTML text or an attribute.
|
||||
//
|
||||
// Every interpolated value here is a slug or a base URL, both of which the
|
||||
// operator controls, but escaping them anyway means a malformed configuration
|
||||
// cannot turn the snippet page into an injection vector.
|
||||
func htmlEscape(s string) string {
|
||||
return html.EscapeString(s)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue