Compare commits

..

No commits in common. "ca9332180da1c7167f6c1843bfafd41df27f61c5" and "a3b261dbe4a7ca42543d749d686a677028150384" have entirely different histories.

3 changed files with 307 additions and 217 deletions

View File

@ -1,96 +0,0 @@
# Codeless `.bnp` — Declarative Plugins (WO-WZ-020)
A **codeless `.bnp`** is a plugin artifact with **no `plugin.wasm`**: pure
declaration the host runs. No wazero compile, no instance pool, no capability
binding — zero guest code ever executes. This is the end-state of "injection,
not reliance" for themes, content sites, and template-only block packs: the
plugin is *data the host runs*, not a program that links a library. No Go, no
`block/core` dependency, no toolchain beyond the `ninja` CLI.
## The classifier — exactly one rule
`ninja plugin build` classifies by repo shape:
- **No Go source at the repo root → codeless.** The manifest is synthesized
from `plugin.mod` + the declarative files below; the artifact packs without
a wasm. `--codeless` asserts this and fails if Go is present.
- **Go source → wasm**, exactly as before. A converted repo DELETES its Go —
partial conversions keep a smaller wasm and still ship `blocks/` +
`seed/` alongside it (both artifact kinds carry the declarative dirs).
## The declarative / logic split
**Codeless-expressible** (declaration or host-rendered template):
- Blocks: a `.ninjatpl` template + declared **data providers** (the CMS's own
provider set — `posts`, `site`, `menus`, `authors`, … ADR 0018). The host
fetches, the engine renders. Defined in `blocks/blocks.yaml` — the SAME
manifest-FS layout core builtin block definitions use.
- Master pages, theme presets, fonts, CSS manifest, icon packs, settings
schema (`manifest.yaml`), assets (host-served), migrations (host-run Goose),
seed data (`seed/seed.json`, applied via the WO-WZ-019 provisioner).
**Requires code (a wasm plugin):**
- A block whose data no declared provider can produce; a *computing* tag or
filter; HTTP handlers; background jobs; Load/Unload logic; RAG fetchers;
media hooks; bridge services; AI tools; `data_dir`. A codeless manifest
declaring any of these is rejected at build (`ninja plugin verify` /
`CodelessHookViolation`) AND at load (cms reader `codelessHookViolation`).
## Repo layout
```
my-theme/
plugin.mod # name/version/kind (data_dir forbidden)
manifest.yaml # optional: theme_presets/bundled_fonts/settings_schema
# (JSON file refs), master_pages (JSON file),
# required_icon_packs, css {...}, dependencies [...]
blocks/
blocks.yaml # key/title/category/schema/template/providers per block
hero.schema.json
hero.ninjatpl
templates/ # reserved for host-rendered page templates (future)
seed/
seed.json # settings {merge/override/ensure}, media, pages, menu_items
hero.jpg # media bytes referenced by seed.json entries
assets/ # host-served statics
migrations/ # Goose SQL, host-run under the plugin schema
```
`ninja plugin build``<name>-<version>.bnp` (tar.zst, no `plugin.wasm`,
`manifest.pb` with `codeless: true`). `ninja plugin verify` re-runs the
loader's checks standalone.
## How the host runs it (cms `plugin/codeless_loader.go`)
- The `.bnp` reader accepts a missing `plugin.wasm` iff `manifest.codeless`,
and rejects codeless manifests with computing-hook declarations.
- Load: `blocks/``blocks.LoadManifest``Registry.RegisterDefinition`
rendering flows through the existing definition engine (providers +
ninjatpl + layer fallback, WO-089..096). `seed/seed.json` → provisioner
(`EnsureMedia`/`MergeSiteSettings`/`EnsureSetting`/`EnsurePage`/
`EnsureMenuItem`), idempotent by construction. Presets/fonts/CSS/master
pages/settings schema come from the manifest exactly as for wasm plugins.
- Unload/disable: definitions unregister; seeded content stays (it is site
data, not runtime state). Uninstall: normal teardown (schema/role drop,
artifact removal).
- Hot-swap: codeless→codeless swaps run migrations first, then re-register
definitions and re-apply seed. Wasm↔codeless transitions require
uninstall + reinstall.
## Seed schema (`seed/seed.json`)
```json
{
"settings": {"merge": {"k": "v"}, "override": {"k": "v"}, "ensure": {"k": "v"}},
"media": [{"id": "<uuid>", "file": "hero.jpg", "alt": "", "folder": ""}],
"pages": [{"slug": "/", "title": "Home", "template_key": "landing",
"parent_slug": "", "reconcile_blocks": false,
"blocks": [{"block_key": "hero", "title": "Hero",
"content": {}, "slot": "main", "sort_order": 1}]}],
"menu_items": [{"menu": "main", "label": "Home", "page_slug": "/", "sort_order": 1}]
}
```
Media `id` is the deterministic, template-referable key (`media:<uuid>`,
`{% img %}`); bytes live in `seed/` next to the JSON. Apply order:
media → settings → pages → menu items.

View File

@ -51,6 +51,7 @@ type SiteSettingsData struct {
AdminBypassMode string // "maintenance", "coming_soon", or "" if not bypassing AdminBypassMode string // "maintenance", "coming_soon", or "" if not bypassing
Toolbar ToolbarData Toolbar ToolbarData
LLMsTxtEnabled bool // Whether llms.txt is enabled for AI content discovery LLMsTxtEnabled bool // Whether llms.txt is enabled for AI content discovery
TurnstileSiteKey string // Cloudflare Turnstile site key for bot protection
RSSFeedURL string // URL to the RSS feed (e.g., "/rss"), empty to disable RSSFeedURL string // URL to the RSS feed (e.g., "/rss"), empty to disable
RSSFeedTitle string // Feed title for discovery link RSSFeedTitle string // Feed title for discovery link
} }
@ -228,6 +229,16 @@ func ParseSiteSettings(doc map[string]any) SiteSettingsData {
} }
} }
// Turnstile bot protection (from site settings)
if turnstileData, ok := siteData["turnstile"].(map[string]any); ok {
// Only set site key if Turnstile is enabled
if enabled, ok := turnstileData["enabled"].(bool); ok && enabled {
if v, ok := turnstileData["site_key"].(string); ok {
settings.TurnstileSiteKey = v
}
}
}
// RSS feed auto-discovery (injected by page handler from system page) // RSS feed auto-discovery (injected by page handler from system page)
if v, ok := siteData["rss_feed_url"].(string); ok { if v, ok := siteData["rss_feed_url"].(string); ok {
settings.RSSFeedURL = v settings.RSSFeedURL = v
@ -530,6 +541,10 @@ templ Head(data HeadData) {
if data.Settings.CustomHeadScripts != "" { if data.Settings.CustomHeadScripts != "" {
@templ.Raw(data.Settings.CustomHeadScripts) @templ.Raw(data.Settings.CustomHeadScripts)
} }
// Cloudflare Turnstile invisible bot protection
if data.Settings.TurnstileSiteKey != "" {
@turnstileScript(data.Settings.TurnstileSiteKey)
}
if data.StructuredData != "" { if data.StructuredData != "" {
@structuredDataScript(data.StructuredData) @structuredDataScript(data.StructuredData)
} }
@ -542,6 +557,117 @@ func structuredDataScript(jsonLD string) templ.Component {
return templ.Raw(`<script type="application/ld+json">` + jsonLD + `</script>`) return templ.Raw(`<script type="application/ld+json">` + jsonLD + `</script>`)
} }
// turnstileScript renders the Cloudflare Turnstile invisible bot protection
templ turnstileScript(siteKey string) {
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit" async defer></script>
<script data-turnstile-key={ siteKey }>
(function(){
var siteKey = document.currentScript.getAttribute('data-turnstile-key');
if (!siteKey) return;
// Store active widget IDs per form
var formWidgets = new WeakMap();
// Initialize Turnstile when API is ready
function initTurnstile() {
if (typeof turnstile === 'undefined') {
setTimeout(initTurnstile, 100);
return;
}
// Find all forms that POST to /api/* endpoints
function instrumentForm(form) {
// Skip if already instrumented
if (formWidgets.has(form)) return;
var action = form.action || '';
var hxPost = form.getAttribute('hx-post') || '';
// Only protect /api/* POST endpoints
if (!action.includes('/api/') && !hxPost.includes('/api/')) return;
// Skip exempted endpoints
var exempted = ['/api/track', '/api/webhooks/'];
for (var i = 0; i < exempted.length; i++) {
if (action.includes(exempted[i]) || hxPost.includes(exempted[i])) return;
}
// Create container for widget
var container = document.createElement('div');
container.className = 'cf-turnstile-container';
container.style.cssText = 'position:absolute;left:-9999px;';
form.appendChild(container);
// Render invisible widget
var widgetId = turnstile.render(container, {
sitekey: siteKey,
size: 'invisible',
callback: function(token) {
// Store token in hidden field
var input = form.querySelector('input[name="cf-turnstile-response"]');
if (!input) {
input = document.createElement('input');
input.type = 'hidden';
input.name = 'cf-turnstile-response';
form.appendChild(input);
}
input.value = token;
}
});
formWidgets.set(form, widgetId);
}
// Instrument existing forms
document.querySelectorAll('form').forEach(instrumentForm);
// Watch for new forms (dynamic content / HTMX)
var observer = new MutationObserver(function(mutations) {
mutations.forEach(function(mutation) {
mutation.addedNodes.forEach(function(node) {
if (node.nodeName === 'FORM') {
instrumentForm(node);
}
if (node.querySelectorAll) {
node.querySelectorAll('form').forEach(instrumentForm);
}
});
});
});
observer.observe(document.body, { childList: true, subtree: true });
// HTMX integration: add token to request parameters
document.body.addEventListener('htmx:configRequest', function(evt) {
var form = evt.detail.elt.closest('form');
if (!form || !formWidgets.has(form)) return;
var widgetId = formWidgets.get(form);
var token = turnstile.getResponse(widgetId);
if (token) {
evt.detail.parameters['cf-turnstile-response'] = token;
}
});
// Reset widgets after HTMX swap
document.body.addEventListener('htmx:afterSwap', function(evt) {
if (evt.detail.target.querySelectorAll) {
evt.detail.target.querySelectorAll('form').forEach(instrumentForm);
}
});
}
// Start initialization
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initTurnstile);
} else {
initTurnstile();
}
})();
</script>
}
// AdminBypassBanner renders a banner when admin is bypassing maintenance/coming_soon mode // AdminBypassBanner renders a banner when admin is bypassing maintenance/coming_soon mode
// This should be rendered at the very start of the <body> to push down all content // This should be rendered at the very start of the <body> to push down all content
templ AdminBypassBanner(settings SiteSettingsData) { templ AdminBypassBanner(settings SiteSettingsData) {

File diff suppressed because one or more lines are too long