Compare commits
2 Commits
a3b261dbe4
...
ca9332180d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ca9332180d | ||
|
|
7a82028618 |
96
docs/codeless-bnp.md
Normal file
96
docs/codeless-bnp.md
Normal file
@ -0,0 +1,96 @@
|
||||
# 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.
|
||||
@ -51,7 +51,6 @@ type SiteSettingsData struct {
|
||||
AdminBypassMode string // "maintenance", "coming_soon", or "" if not bypassing
|
||||
Toolbar ToolbarData
|
||||
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
|
||||
RSSFeedTitle string // Feed title for discovery link
|
||||
}
|
||||
@ -229,16 +228,6 @@ 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)
|
||||
if v, ok := siteData["rss_feed_url"].(string); ok {
|
||||
settings.RSSFeedURL = v
|
||||
@ -541,10 +530,6 @@ templ Head(data HeadData) {
|
||||
if data.Settings.CustomHeadScripts != "" {
|
||||
@templ.Raw(data.Settings.CustomHeadScripts)
|
||||
}
|
||||
// Cloudflare Turnstile invisible bot protection
|
||||
if data.Settings.TurnstileSiteKey != "" {
|
||||
@turnstileScript(data.Settings.TurnstileSiteKey)
|
||||
}
|
||||
if data.StructuredData != "" {
|
||||
@structuredDataScript(data.StructuredData)
|
||||
}
|
||||
@ -557,117 +542,6 @@ func structuredDataScript(jsonLD string) templ.Component {
|
||||
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
|
||||
// This should be rendered at the very start of the <body> to push down all content
|
||||
templ AdminBypassBanner(settings SiteSettingsData) {
|
||||
|
||||
File diff suppressed because one or more lines are too long
Loading…
x
Reference in New Issue
Block a user