Compare commits
No commits in common. "ca9332180da1c7167f6c1843bfafd41df27f61c5" and "a3b261dbe4a7ca42543d749d686a677028150384" have entirely different histories.
ca9332180d
...
a3b261dbe4
@ -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.
|
|
||||||
@ -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
Loading…
x
Reference in New Issue
Block a user