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
|
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
|
||||||
}
|
}
|
||||||
@ -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)
|
// 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
|
||||||
@ -541,10 +530,6 @@ 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)
|
||||||
}
|
}
|
||||||
@ -557,117 +542,6 @@ 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
Loading…
x
Reference in New Issue
Block a user