Initial public push: docs cosmos v4 + AI module + framework groundwork
This is the snapshot the production landing site (nibiru-framework.com) is deployed from. Brings together the recent splash + docs migration to the v4 "Cosmos" design system, the new in-framework AI module, and the framework groundwork that backs the framework-reference extraction. What lands: - docs/: Astro + Starlight site with the v4 dark cosmic palette, GalaxyHero canvas constellation, Mission Control chat (wired to /api/oracle → api.neuronetz.ai via providers.mjs Ollama), 5-panel MMVC stage (Model · AI · Module · Controller · View), translated EN/DE/JA/ES/FR content, PWA + sitemap + llms.txt + Umami analytics. - docs/design-system/: canonical mockup bundle (source/index-v2.html for splash, source/docs-system.html + preview/ for docs, SPEC.md, tokens). - docs/scripts/extraction/framework-reference-v2.md: deep framework reference (~1.6k lines, file:line citations, every public factory and idiom — basis for the LoRA training corpus. - application/module/ai/: AI module with chat / embed / RAG / agent plugins, plus pdoQuery / httpGet / fileRead tools and Modelfile + smoke-test in training/. - application/module/users/: user / ACL / form-factory traits used as the reference plugin pattern for the framework docs. - application/settings/config/database/: schema + seed migrations including the AI module tables (200–203). - Form factory + autogenerator changes the framework-reference-v2 covers. Production secrets stay out: docs/.env, settings.production.ini and ai.production.ini are all gitignored (.example files are in tree). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
BIN
docs/src/assets/houston.webp
Normal file
BIN
docs/src/assets/houston.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 96 KiB |
17
docs/src/assets/nibiru-mark.svg
Normal file
17
docs/src/assets/nibiru-mark.svg
Normal file
@@ -0,0 +1,17 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">
|
||||
<!-- Lotus mark drawn from the actual brand logo -->
|
||||
<!-- Center petal (deepest violet, vertical) -->
|
||||
<path d="M32 8 C 28 22, 28 38, 32 50 C 36 38, 36 22, 32 8 Z" fill="#5e548c" opacity="0.85"/>
|
||||
<!-- Inner-left violet petal -->
|
||||
<path d="M22 14 C 16 24, 18 38, 28 46 C 30 36, 28 22, 22 14 Z" fill="#7c70ab" opacity="0.85"/>
|
||||
<!-- Inner-right violet petal -->
|
||||
<path d="M42 14 C 48 24, 46 38, 36 46 C 34 36, 36 22, 42 14 Z" fill="#7c70ab" opacity="0.85"/>
|
||||
<!-- Outer-left blue petal -->
|
||||
<path d="M10 22 C 6 32, 12 44, 24 48 C 22 38, 18 26, 10 22 Z" fill="#7db7dc" opacity="0.85"/>
|
||||
<!-- Outer-right blue petal -->
|
||||
<path d="M54 22 C 58 32, 52 44, 40 48 C 42 38, 46 26, 54 22 Z" fill="#7db7dc" opacity="0.85"/>
|
||||
<!-- Far-left blue petal -->
|
||||
<path d="M2 28 C 4 38, 14 46, 22 46 C 18 38, 10 30, 2 28 Z" fill="#a4cae0" opacity="0.7"/>
|
||||
<!-- Far-right blue petal -->
|
||||
<path d="M62 28 C 60 38, 50 46, 42 46 C 46 38, 54 30, 62 28 Z" fill="#a4cae0" opacity="0.7"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
250
docs/src/components/BrandHeader.astro
Normal file
250
docs/src/components/BrandHeader.astro
Normal file
@@ -0,0 +1,250 @@
|
||||
---
|
||||
import Default from '@astrojs/starlight/components/Header.astro';
|
||||
import Search from 'virtual:starlight/components/Search';
|
||||
|
||||
/**
|
||||
* Three branches:
|
||||
* - Splash pages → custom marketing <header class="nav">
|
||||
* - Docs pages (rest) → mockup's <header class="doc-header"> with
|
||||
* brand-mark, primary doc-nav, search-inline,
|
||||
* lang-switcher pills (per docs-page-mockup.html)
|
||||
* - Anything weird → Starlight Default (fallback safety net)
|
||||
*/
|
||||
const route = Astro.locals.starlightRoute;
|
||||
const isSplash = route?.entry?.data?.template === 'splash';
|
||||
const isDocs = !!route?.entry && !isSplash;
|
||||
|
||||
const currentLocale = route?.locale ?? 'en';
|
||||
const localeLabels = { en: 'EN', de: 'DE', ja: 'JA', es: 'ES', fr: 'FR' } as const;
|
||||
const supportedLocales = Object.keys(localeLabels) as Array<keyof typeof localeLabels>;
|
||||
|
||||
// Swap the leading /xx/ segment of the current path to switch language.
|
||||
const currentPath = Astro.url.pathname;
|
||||
function localizedPath(target: string): string {
|
||||
if (currentPath.startsWith(`/${currentLocale}/`)) {
|
||||
return currentPath.replace(`/${currentLocale}/`, `/${target}/`);
|
||||
}
|
||||
return `/${target}/`;
|
||||
}
|
||||
|
||||
// The five primary nav targets — kept in sync with the splash header.
|
||||
const docNav: Array<{ label: string; href: string; current?: boolean }> = [
|
||||
{ label: 'Docs', href: `/${currentLocale}/start/installation/` },
|
||||
{ label: 'MMVC', href: `/${currentLocale}/core/architecture/` },
|
||||
{ label: 'AI module',href: `/${currentLocale}/ai/module/overview/` },
|
||||
{ label: 'CLI', href: `/${currentLocale}/start/quick-start/` },
|
||||
{ label: 'Showcase', href: `/${currentLocale}/showcase/` },
|
||||
];
|
||||
---
|
||||
|
||||
{isSplash ? (
|
||||
<header class="nav" id="nav">
|
||||
<div class="container nav-row">
|
||||
<a class="brand" href="/en/" aria-label="Nibiru — home">
|
||||
<img class="brand-mark nav-logo" src="/img/nibiru-lotus.png" alt="" width="60" height="36" />
|
||||
<span class="brand-name">Nibiru</span>
|
||||
<span class="nav-version"><span class="dot"></span>v0.9.2</span>
|
||||
</a>
|
||||
<nav class="nav-links" aria-label="Primary">
|
||||
<a href="/en/core/architecture/">MMVC</a>
|
||||
<a href="/en/ai/oracle/">Mission Control</a>
|
||||
<a href="/en/start/quick-start/">Code</a>
|
||||
<a href="/en/start/installation/">Install</a>
|
||||
<a href="/en/start/what-is-nibiru/">Docs</a>
|
||||
</nav>
|
||||
<div class="nav-cta">
|
||||
<a class="btn btn-ghost btn-icon-only" href="https://github.com/alllinux/Nibiru" aria-label="GitHub">
|
||||
<svg class="ico" viewBox="0 0 16 16" fill="currentColor">
|
||||
<path d="M8 0C3.58 0 0 3.58 0 8a8 8 0 0 0 5.47 7.59c.4.07.55-.17.55-.38v-1.36c-2.23.48-2.7-1.07-2.7-1.07-.36-.93-.89-1.18-.89-1.18-.73-.5.05-.49.05-.49.81.06 1.24.83 1.24.83.72 1.24 1.89.88 2.35.67.07-.52.28-.88.51-1.08-1.78-.2-3.65-.89-3.65-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.13 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.11.16 1.93.08 2.13.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48v2.2c0 .21.15.46.55.38A8.01 8.01 0 0 0 16 8c0-4.42-3.58-8-8-8z"/>
|
||||
</svg>
|
||||
</a>
|
||||
<a class="btn btn-solid" href="#download">Download</a>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
) : isDocs ? (
|
||||
<header class="doc-header">
|
||||
<div class="doc-header-row">
|
||||
<a class="brand" href={`/${currentLocale}/`} aria-label="Nibiru — home">
|
||||
<img class="brand-mark" src="/img/nibiru-lotus.png" alt="" width="50" height="30" />
|
||||
<span class="brand-name">Nibiru<em> docs</em></span>
|
||||
<span class="nav-version"><span class="dot"></span>v0.9.2</span>
|
||||
</a>
|
||||
<nav class="doc-nav" aria-label="Primary">
|
||||
{docNav.map((item) => (
|
||||
<a href={item.href} aria-current={Astro.url.pathname.startsWith(item.href) ? 'page' : undefined}>{item.label}</a>
|
||||
))}
|
||||
</nav>
|
||||
<div class="doc-header-tools">
|
||||
<Search />
|
||||
<div class="lang-switcher" aria-label="Language">
|
||||
{supportedLocales.map((loc) => (
|
||||
<a href={localizedPath(loc)} aria-current={loc === currentLocale ? 'true' : undefined}>{localeLabels[loc]}</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
) : (
|
||||
<Default {...Astro.props}>
|
||||
<slot />
|
||||
</Default>
|
||||
)}
|
||||
|
||||
<script is:inline>
|
||||
(function () {
|
||||
// The mockup's nibiru-scene.js owns nav-condense on splash pages.
|
||||
// For non-splash pages we still tag the document so the docs nav can
|
||||
// shrink the same way.
|
||||
if (document.querySelector('header.nav')) return;
|
||||
const root = document.documentElement;
|
||||
const onScroll = function () {
|
||||
if (window.scrollY > 24) root.setAttribute('data-nav-condensed', '');
|
||||
else root.removeAttribute('data-nav-condensed');
|
||||
};
|
||||
window.addEventListener('scroll', onScroll, { passive: true });
|
||||
onScroll();
|
||||
})();
|
||||
|
||||
(function () {
|
||||
// The Oracle launcher must live at document.body level — not inside <header>.
|
||||
// The header has `backdrop-filter` which makes it a containing block for
|
||||
// position:fixed descendants, so a button rendered inside it would anchor
|
||||
// to the header (top-right) instead of the viewport (bottom-right).
|
||||
if (document.getElementById('oracle-launcher')) return;
|
||||
|
||||
const launcher = document.createElement('button');
|
||||
launcher.id = 'oracle-launcher';
|
||||
launcher.type = 'button';
|
||||
launcher.setAttribute('aria-label', 'Open Nibiru Oracle (AI assistant)');
|
||||
launcher.title = 'Ask the Oracle';
|
||||
|
||||
const panel = document.createElement('aside');
|
||||
panel.id = 'oracle-panel';
|
||||
panel.setAttribute('role', 'dialog');
|
||||
panel.setAttribute('aria-label', 'Nibiru Oracle');
|
||||
panel.innerHTML = `
|
||||
<header>
|
||||
<div>
|
||||
<h3>Nibiru Oracle</h3>
|
||||
<div class="oracle-subtitle">Trained on the framework itself</div>
|
||||
</div>
|
||||
<button class="oracle-close" type="button" aria-label="Close">✕</button>
|
||||
</header>
|
||||
<div id="oracle-messages">
|
||||
<div class="oracle-msg system">
|
||||
Ask anything about Nibiru — routing, modules, the CLI, real-world examples.
|
||||
</div>
|
||||
</div>
|
||||
<form id="oracle-form" autocomplete="off">
|
||||
<textarea name="q" placeholder="How do I create a new module?" rows="1" required></textarea>
|
||||
<button type="submit">Send</button>
|
||||
</form>
|
||||
`;
|
||||
|
||||
document.body.appendChild(launcher);
|
||||
document.body.appendChild(panel);
|
||||
|
||||
const closeBtn = panel.querySelector('.oracle-close');
|
||||
const form = panel.querySelector('#oracle-form');
|
||||
const input = form.querySelector('textarea');
|
||||
const submitBtn = form.querySelector('button[type="submit"]');
|
||||
const list = panel.querySelector('#oracle-messages');
|
||||
const history = [];
|
||||
|
||||
function track(event, data) {
|
||||
if (typeof window.umami?.track === 'function') {
|
||||
try { window.umami.track(event, data || {}); } catch (e) {}
|
||||
}
|
||||
}
|
||||
|
||||
function open() {
|
||||
panel.classList.add('is-open');
|
||||
setTimeout(() => input.focus(), 80);
|
||||
track('oracle-open');
|
||||
}
|
||||
function close() { panel.classList.remove('is-open'); }
|
||||
|
||||
launcher.addEventListener('click', () =>
|
||||
panel.classList.contains('is-open') ? close() : open()
|
||||
);
|
||||
closeBtn.addEventListener('click', close);
|
||||
document.addEventListener('keydown', (e) => { if (e.key === 'Escape') close(); });
|
||||
|
||||
function renderMarkdown(md) {
|
||||
md = md.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
md = md.replace(/```([a-z]*)\n([\s\S]*?)```/g, (_, lang, code) =>
|
||||
`<pre><code class="lang-${lang}">${code}</code></pre>`);
|
||||
md = md.replace(/`([^`\n]+?)`/g, '<code>$1</code>');
|
||||
md = md.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
|
||||
md = md.replace(/\*([^*]+)\*/g, '<em>$1</em>');
|
||||
md = md.replace(/\n\n+/g, '</p><p>');
|
||||
md = md.replace(/\n/g, '<br/>');
|
||||
return '<p>' + md + '</p>';
|
||||
}
|
||||
|
||||
function append(role, text, sources) {
|
||||
const el = document.createElement('div');
|
||||
el.className = 'oracle-msg ' + role;
|
||||
if (role === 'assistant') {
|
||||
el.innerHTML = renderMarkdown(text);
|
||||
if (sources && sources.length) {
|
||||
const cites = document.createElement('div');
|
||||
cites.style.marginTop = '0.55rem';
|
||||
cites.style.fontSize = '0.75rem';
|
||||
cites.innerHTML = '<span style="opacity:0.7">Sources: </span>' +
|
||||
sources.map((s, i) => `<a class="oracle-cite" href="${s.url}">${i + 1}. ${s.title}</a>`).join(' ');
|
||||
el.appendChild(cites);
|
||||
}
|
||||
} else {
|
||||
el.textContent = text;
|
||||
}
|
||||
list.appendChild(el);
|
||||
list.scrollTop = list.scrollHeight;
|
||||
return el;
|
||||
}
|
||||
|
||||
form.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const q = input.value.trim();
|
||||
if (!q) return;
|
||||
append('user', q);
|
||||
history.push({ role: 'user', content: q });
|
||||
input.value = '';
|
||||
submitBtn.disabled = true;
|
||||
track('oracle-question', {
|
||||
lang: (document.documentElement.lang || 'en').slice(0, 2),
|
||||
turn: history.filter((m) => m.role === 'user').length,
|
||||
});
|
||||
const placeholder = append('assistant', 'Consulting the docs…');
|
||||
try {
|
||||
const res = await fetch('/api/oracle', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ messages: history }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const t = await res.text();
|
||||
placeholder.innerHTML = renderMarkdown(`The Oracle is silent: ${t || res.statusText}`);
|
||||
return;
|
||||
}
|
||||
const data = await res.json();
|
||||
placeholder.remove();
|
||||
append('assistant', data.answer || '(no response)', data.sources || []);
|
||||
history.push({ role: 'assistant', content: data.answer });
|
||||
} catch (err) {
|
||||
placeholder.innerHTML = renderMarkdown(`Connection lost: ${err.message}`);
|
||||
} finally {
|
||||
submitBtn.disabled = false;
|
||||
input.focus();
|
||||
}
|
||||
});
|
||||
|
||||
input.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
form.requestSubmit();
|
||||
}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
55
docs/src/components/BrandHero.astro
Normal file
55
docs/src/components/BrandHero.astro
Normal file
@@ -0,0 +1,55 @@
|
||||
---
|
||||
const { data } = Astro.locals.starlightRoute.entry;
|
||||
const hero = data.hero;
|
||||
|
||||
const title = hero?.title ?? data.title ?? '';
|
||||
const tagline = hero?.tagline ?? data.description ?? '';
|
||||
const eyebrow = hero?.eyebrow ?? 'Modular MMVC PHP framework';
|
||||
const actions = Array.isArray(hero?.actions) ? hero.actions : [];
|
||||
|
||||
const shouldRender = !!hero;
|
||||
---
|
||||
|
||||
{shouldRender && (
|
||||
<section class="atelier-hero">
|
||||
<span class="atelier-hero__number" aria-hidden="true">01</span>
|
||||
|
||||
<div class="atelier-hero__grid">
|
||||
<div class="atelier-hero__copy">
|
||||
{eyebrow && <p class="atelier-hero__eyebrow">{eyebrow}</p>}
|
||||
{title && <h1 class="atelier-hero__title" set:html={title} />}
|
||||
{tagline && <p class="atelier-hero__lede" set:html={tagline} />}
|
||||
|
||||
{actions.length > 0 && (
|
||||
<div class="atelier-hero__cta">
|
||||
{actions.map((a: any) => (
|
||||
<a
|
||||
href={a.link}
|
||||
class={`atelier-button ${
|
||||
a.variant === 'minimal' || a.variant === 'secondary'
|
||||
? 'atelier-button--ghost'
|
||||
: 'atelier-button--primary'
|
||||
}`}
|
||||
>
|
||||
<span>{a.text}</span>
|
||||
{a.icon === 'right-arrow' && <span class="atelier-button__arrow" aria-hidden="true">→</span>}
|
||||
{a.icon === 'external' && <span class="atelier-button__arrow" aria-hidden="true">↗</span>}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div class="atelier-hero__art" aria-hidden="true">
|
||||
<img
|
||||
class="atelier-hero__mark"
|
||||
src="/img/nibiru-logo.png"
|
||||
alt=""
|
||||
loading="eager"
|
||||
width="280"
|
||||
height="280"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
53
docs/src/components/CodeCard.astro
Normal file
53
docs/src/components/CodeCard.astro
Normal file
@@ -0,0 +1,53 @@
|
||||
---
|
||||
/**
|
||||
* CodeCard — dark cosmic terminal pane (v3 design system).
|
||||
*
|
||||
* <CodeCard lang="bash" meta="bash · 5 lines" code={`...`} />
|
||||
*
|
||||
* The `code` prop is dumped raw via set:html so MDX never sees its contents,
|
||||
* which means template-string newlines (including blank lines) and HTML token
|
||||
* spans (`<span class="tk-k">`, `<span class="tk-s">` …) come through intact.
|
||||
*
|
||||
* • Use \\u00a0 / if you need preserved whitespace.
|
||||
* • Wrap keywords with <span class="tk-k">…</span>, strings with tk-s, etc.
|
||||
*
|
||||
* Tabs is an array — first entry is the active one. `meta` is the right-hand
|
||||
* caption ("php · 9 lines"). Line numbers in the gutter are auto-derived from
|
||||
* the `code` newline count.
|
||||
*/
|
||||
interface Props {
|
||||
tabs?: string[];
|
||||
lang?: string;
|
||||
meta?: string;
|
||||
code: string;
|
||||
startLine?: number;
|
||||
}
|
||||
|
||||
const {
|
||||
tabs,
|
||||
lang = 'bash',
|
||||
meta,
|
||||
code,
|
||||
startLine = 1,
|
||||
} = Astro.props as Props;
|
||||
|
||||
const tabLabels = tabs && tabs.length ? tabs : [lang];
|
||||
const lineCount = code.split('\n').length;
|
||||
const gutter = Array.from({ length: lineCount }, (_, i) => `${startLine + i}`.padStart(2, ' ')).join('\n');
|
||||
const metaLabel = meta ?? `${lang} · ${lineCount} line${lineCount === 1 ? '' : 's'}`;
|
||||
---
|
||||
|
||||
<div class="code-card">
|
||||
<div class="code-card__head">
|
||||
<div class="code-card__tabs">
|
||||
{tabLabels.map((label, i) => (
|
||||
<span class={`code-card__tab${i === 0 ? ' is-active' : ''}`}>{label}</span>
|
||||
))}
|
||||
</div>
|
||||
<div class="code-card__meta">{metaLabel}</div>
|
||||
</div>
|
||||
<div class="code-card__body">
|
||||
<pre class="code-card__gutter" aria-hidden="true">{gutter}</pre>
|
||||
<pre class="code-card__pre" set:html={code} />
|
||||
</div>
|
||||
</div>
|
||||
21
docs/src/components/CometTrail.astro
Normal file
21
docs/src/components/CometTrail.astro
Normal file
@@ -0,0 +1,21 @@
|
||||
---
|
||||
/**
|
||||
* CometTrail — pointer-following particle trail rendered as a fixed canvas
|
||||
* overlay. Pure vanilla canvas2d, additive blending so the trail glows.
|
||||
* Skipped on touch devices and under prefers-reduced-motion.
|
||||
*/
|
||||
---
|
||||
<canvas id="comet-trail" aria-hidden="true"></canvas>
|
||||
|
||||
<style is:global>
|
||||
#comet-trail {
|
||||
position: fixed; inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: 100;
|
||||
width: 100%; height: 100%;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce), (pointer: coarse) {
|
||||
#comet-trail { display: none; }
|
||||
}
|
||||
</style>
|
||||
|
||||
298
docs/src/components/EditorialContent.astro
Normal file
298
docs/src/components/EditorialContent.astro
Normal file
@@ -0,0 +1,298 @@
|
||||
---
|
||||
/**
|
||||
* EditorialContent — the prose tail of the splash. Lives between Spacecraft
|
||||
* and the footer; carries the production-scale evidence and the entry-point
|
||||
* doc links so visitors who scroll past the demo end with a clear next step.
|
||||
*
|
||||
* 05 — In production (Maschinen-Stockert showcase + scale quote)
|
||||
* 06 — Pick a thread (4 link cards into the docs)
|
||||
* 07 — Marquee close
|
||||
*/
|
||||
---
|
||||
{/* ============================================================
|
||||
05 · IN PRODUCTION
|
||||
============================================================ */}
|
||||
<section class="section" id="production" aria-labelledby="prod-h">
|
||||
<div class="container">
|
||||
<div class="section-head">
|
||||
<span class="eyebrow">In production · 05</span>
|
||||
<h2 id="prod-h" class="section-title">Real apps. <span class="accent">Real revenue.</span></h2>
|
||||
<p class="section-sub">
|
||||
The flagship Nibiru deployment is the <strong>Maschinen Stockert</strong> group — two
|
||||
repos powering one of Austria's larger industrial-machinery e-commerce platforms.
|
||||
18 shared modules, ~74 000 lines of PHP, 161 timestamped SQL migrations into
|
||||
production. No service container.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="prod-grid">
|
||||
<a class="prod-card" href="/en/showcase/projects/#maschinen-stockertde--public-catalogue">
|
||||
<div class="prod-card__head">
|
||||
<span class="prod-card__tag">Public catalogue</span>
|
||||
<span class="prod-card__url">maschinen-stockert.de</span>
|
||||
</div>
|
||||
<p class="prod-card__desc">
|
||||
Multilingual content, Elasticsearch search, SEO-friendly URLs, Yumpu PDF flipbooks.
|
||||
</p>
|
||||
<div class="prod-card__stats">
|
||||
<div><span class="v">10</span><span class="l">Controllers</span></div>
|
||||
<div><span class="v">18</span><span class="l">Modules</span></div>
|
||||
<div><span class="v">150</span><span class="l">Templates</span></div>
|
||||
<div><span class="v">36 289</span><span class="l">Lines of PHP</span></div>
|
||||
</div>
|
||||
<span class="prod-card__cta">Read the case <span class="arrow">→</span></span>
|
||||
</a>
|
||||
|
||||
<a class="prod-card" href="/en/showcase/projects/#datamaschinen-stockertde--admin--api">
|
||||
<div class="prod-card__head">
|
||||
<span class="prod-card__tag">Admin & API</span>
|
||||
<span class="prod-card__url">data.maschinen-stockert.de</span>
|
||||
</div>
|
||||
<p class="prod-card__desc">
|
||||
Page-tree CMS, role-based ACL, public-API whitelist, Machineryscout indexer.
|
||||
</p>
|
||||
<div class="prod-card__stats">
|
||||
<div><span class="v">36</span><span class="l">Controllers</span></div>
|
||||
<div><span class="v">18</span><span class="l">Modules</span></div>
|
||||
<div><span class="v">348</span><span class="l">Templates</span></div>
|
||||
<div><span class="v">37 369</span><span class="l">Lines of PHP</span></div>
|
||||
</div>
|
||||
<span class="prod-card__cta">Read the case <span class="arrow">→</span></span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<blockquote class="prod-quote">
|
||||
Two repos, 18 shared modules, 161 timestamped SQL migrations into production with
|
||||
no migration framework, 74 000 lines of PHP with no service container. The team
|
||||
that built and runs it is small.
|
||||
</blockquote>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ============================================================
|
||||
06 · PICK A THREAD
|
||||
============================================================ */}
|
||||
<section class="section" id="threads" aria-labelledby="threads-h">
|
||||
<div class="container">
|
||||
<div class="section-head">
|
||||
<span class="eyebrow">Where next · 06</span>
|
||||
<h2 id="threads-h" class="section-title">Pick a <span class="accent">thread.</span></h2>
|
||||
<p class="section-sub">
|
||||
Four ways into the framework. None of them takes more than five minutes to start.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="thread-grid">
|
||||
<a class="thread-card" href="/en/start/what-is-nibiru/">
|
||||
<span class="thread-card__num">01</span>
|
||||
<h3 class="thread-card__title">What is Nibiru?</h3>
|
||||
<p class="thread-card__desc">The 90-second tour: MMVC, the dispatcher, the request lifecycle.</p>
|
||||
<span class="thread-card__cta">Read <span class="arrow">→</span></span>
|
||||
</a>
|
||||
<a class="thread-card" href="/en/why-nibiru/">
|
||||
<span class="thread-card__num">02</span>
|
||||
<h3 class="thread-card__title">Why Nibiru, not Laravel</h3>
|
||||
<p class="thread-card__desc">Five things Nibiru does differently — each backed by real production code.</p>
|
||||
<span class="thread-card__cta">Read <span class="arrow">→</span></span>
|
||||
</a>
|
||||
<a class="thread-card" href="/en/start/local-testing/">
|
||||
<span class="thread-card__num">03</span>
|
||||
<h3 class="thread-card__title">Run it locally</h3>
|
||||
<p class="thread-card__desc">Three paths from clone to running site, including the Oracle on your own Ollama.</p>
|
||||
<span class="thread-card__cta">Read <span class="arrow">→</span></span>
|
||||
</a>
|
||||
<a class="thread-card" href="/en/ai/module/overview/">
|
||||
<span class="thread-card__num">04</span>
|
||||
<h3 class="thread-card__title">The AI module</h3>
|
||||
<p class="thread-card__desc">Chat, embeddings, RAG, agents — first-class AI in your Nibiru app.</p>
|
||||
<span class="thread-card__cta">Read <span class="arrow">→</span></span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ============================================================
|
||||
07 · MARQUEE CLOSE
|
||||
============================================================ */}
|
||||
<section class="section section--marquee" aria-hidden="true">
|
||||
<p class="atelier-marquee">Create · Invent · Impress</p>
|
||||
</section>
|
||||
|
||||
<style is:global>
|
||||
/* === Production showcase (Maschinen-Stockert plates) === */
|
||||
.prod-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 22rem), 1fr));
|
||||
gap: 1.25rem;
|
||||
max-width: var(--nibiru-container, 1280px);
|
||||
margin: 0 auto;
|
||||
padding: 0 32px;
|
||||
}
|
||||
.prod-card {
|
||||
display: flex; flex-direction: column; gap: 1rem;
|
||||
background: linear-gradient(180deg, var(--nibiru-night), var(--nibiru-space));
|
||||
border: 1px solid var(--nibiru-line);
|
||||
border-radius: var(--nibiru-radius-2xl);
|
||||
padding: 1.4rem 1.5rem;
|
||||
color: var(--nibiru-star);
|
||||
text-decoration: none;
|
||||
transition: transform 240ms var(--nibiru-ease-out, ease),
|
||||
border-color 240ms ease,
|
||||
box-shadow 240ms ease;
|
||||
}
|
||||
.prod-card:hover {
|
||||
transform: translateY(-2px);
|
||||
border-color: var(--nibiru-nebula-mag);
|
||||
box-shadow: var(--nibiru-shadow-lg);
|
||||
}
|
||||
.prod-card__head {
|
||||
display: flex; align-items: baseline; justify-content: space-between;
|
||||
gap: 1rem; flex-wrap: wrap;
|
||||
}
|
||||
.prod-card__tag {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.69rem;
|
||||
letter-spacing: 0.10em;
|
||||
text-transform: uppercase;
|
||||
color: var(--nibiru-nebula-mag);
|
||||
}
|
||||
.prod-card__url {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.78rem;
|
||||
color: var(--nibiru-muted);
|
||||
}
|
||||
.prod-card__desc {
|
||||
margin: 0;
|
||||
color: rgba(244, 238, 219, 0.75);
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.55;
|
||||
}
|
||||
.prod-card__stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 0.75rem;
|
||||
padding: 0.85rem 0;
|
||||
border-top: 1px solid var(--nibiru-line);
|
||||
border-bottom: 1px solid var(--nibiru-line);
|
||||
}
|
||||
.prod-card__stats div { display: flex; flex-direction: column; gap: 2px; }
|
||||
.prod-card__stats .v {
|
||||
font-family: var(--font-display);
|
||||
font-size: 1rem; font-weight: 500;
|
||||
color: var(--nibiru-star);
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
.prod-card__stats .l {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.62rem;
|
||||
color: var(--nibiru-muted);
|
||||
letter-spacing: 0.10em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.prod-card__cta {
|
||||
margin-top: auto;
|
||||
font-size: 0.92rem;
|
||||
color: var(--nibiru-star);
|
||||
display: inline-flex; align-items: center; gap: 8px;
|
||||
}
|
||||
.prod-card__cta .arrow {
|
||||
font-family: var(--font-mono);
|
||||
transition: transform 200ms ease;
|
||||
}
|
||||
.prod-card:hover .prod-card__cta .arrow { transform: translateX(4px); }
|
||||
|
||||
.prod-quote {
|
||||
max-width: 56rem;
|
||||
margin: 4rem auto 0;
|
||||
padding: 0 32px;
|
||||
font-family: var(--font-display);
|
||||
font-size: clamp(1.15rem, 1rem + 0.6vw, 1.45rem);
|
||||
line-height: 1.55;
|
||||
letter-spacing: -0.015em;
|
||||
color: var(--nibiru-star);
|
||||
border-left: 3px solid var(--nibiru-nebula-mag);
|
||||
padding-left: 1.4rem;
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
.prod-card__stats { grid-template-columns: repeat(2, 1fr); }
|
||||
}
|
||||
|
||||
/* === Pick-a-thread cards === */
|
||||
.thread-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 17rem), 1fr));
|
||||
gap: 1rem;
|
||||
max-width: var(--nibiru-container, 1280px);
|
||||
margin: 0 auto;
|
||||
padding: 0 32px;
|
||||
}
|
||||
.thread-card {
|
||||
position: relative;
|
||||
display: flex; flex-direction: column; gap: 0.7rem;
|
||||
padding: 1.5rem 1.4rem 1.3rem;
|
||||
background: var(--nibiru-night);
|
||||
border: 1px solid var(--nibiru-line);
|
||||
border-radius: var(--nibiru-radius-xl);
|
||||
color: var(--nibiru-star);
|
||||
text-decoration: none;
|
||||
overflow: hidden;
|
||||
transition: transform 240ms ease, border-color 240ms ease, background 240ms ease;
|
||||
}
|
||||
.thread-card::before {
|
||||
content: '';
|
||||
position: absolute; inset: 0;
|
||||
background: radial-gradient(ellipse 80% 60% at 100% 0%, rgba(184, 107, 255, 0.18), transparent 70%);
|
||||
opacity: 0;
|
||||
transition: opacity 240ms ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
.thread-card:hover {
|
||||
transform: translateY(-2px);
|
||||
border-color: var(--nibiru-nebula-mag);
|
||||
background: rgba(28, 15, 58, 0.6);
|
||||
}
|
||||
.thread-card:hover::before { opacity: 1; }
|
||||
.thread-card__num {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.69rem;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
color: var(--nibiru-nebula-mag);
|
||||
}
|
||||
.thread-card__title {
|
||||
margin: 0;
|
||||
font-family: var(--font-display);
|
||||
font-size: 1.15rem;
|
||||
font-weight: 500;
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--nibiru-star);
|
||||
}
|
||||
.thread-card__desc {
|
||||
margin: 0;
|
||||
flex: 1;
|
||||
font-size: 0.92rem;
|
||||
line-height: 1.55;
|
||||
color: rgba(244, 238, 219, 0.7);
|
||||
}
|
||||
.thread-card__cta {
|
||||
font-size: 0.86rem;
|
||||
color: rgba(244, 238, 219, 0.85);
|
||||
display: inline-flex; align-items: center; gap: 8px;
|
||||
margin-top: 0.4rem;
|
||||
}
|
||||
.thread-card__cta .arrow {
|
||||
font-family: var(--font-mono);
|
||||
transition: transform 200ms ease;
|
||||
}
|
||||
.thread-card:hover .thread-card__cta .arrow { transform: translateX(4px); }
|
||||
.thread-card:hover .thread-card__cta { color: var(--nibiru-nebula-mag); }
|
||||
|
||||
/* === Marquee close === */
|
||||
.section--marquee {
|
||||
padding: 6rem 0 7rem;
|
||||
}
|
||||
.section--marquee .atelier-marquee {
|
||||
text-align: center;
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
33
docs/src/components/Footer.astro
Normal file
33
docs/src/components/Footer.astro
Normal file
@@ -0,0 +1,33 @@
|
||||
---
|
||||
/**
|
||||
* Footer override — keeps Starlight's Pagination, drops the duplicate
|
||||
* edit/last-updated meta (already shown in PageTitle's .doc-meta), and
|
||||
* appends the docs-page-mockup .help-strip ("Was this page helpful?").
|
||||
*
|
||||
* Styles live in src/styles/starlight-docs-bridge.css §09 (.help-strip).
|
||||
*/
|
||||
import Pagination from 'virtual:starlight/components/Pagination';
|
||||
|
||||
const route = Astro.locals.starlightRoute;
|
||||
const editUrl = route.editUrl?.toString();
|
||||
---
|
||||
|
||||
<footer class="sl-flex">
|
||||
<Pagination />
|
||||
<aside class="help-strip">
|
||||
<span class="help-strip-text">Was this page helpful?</span>
|
||||
<div class="help-strip-actions">
|
||||
<a href={`https://github.com/alllinux/Nibiru/discussions/new?category=docs-feedback&title=${encodeURIComponent('Feedback: ' + (route.entry.data.title ?? ''))}`}>Yes</a>
|
||||
{editUrl && <a href={editUrl}>Suggest an edit</a>}
|
||||
</div>
|
||||
</aside>
|
||||
</footer>
|
||||
|
||||
<style>
|
||||
@layer starlight.core {
|
||||
footer {
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
218
docs/src/components/GalaxyHero.astro
Normal file
218
docs/src/components/GalaxyHero.astro
Normal file
@@ -0,0 +1,218 @@
|
||||
---
|
||||
/**
|
||||
* GalaxyHero — splash hero, v4 "Cosmos" dialect.
|
||||
*
|
||||
* Full-viewport dark cosmic stage with a vanilla-canvas2d constellation,
|
||||
* gradient-text headline, hero CTA links and a telemetry strip at the
|
||||
* bottom edge. No three.js, no WebGL — works on every browser, respects
|
||||
* prefers-reduced-motion.
|
||||
*
|
||||
* Hero contract (matches the Atelier hero):
|
||||
* data.hero.eyebrow → eyebrow row above the headline
|
||||
* data.hero.title → display headline (HTML allowed; wrap accent words in <em>, end with .period span)
|
||||
* data.hero.tagline → sub copy (HTML allowed)
|
||||
* data.hero.actions → CTA links (variant: primary | secondary | minimal)
|
||||
*/
|
||||
const { data } = Astro.locals.starlightRoute.entry;
|
||||
const hero = data.hero;
|
||||
|
||||
const title = hero?.title ?? data.title ?? '';
|
||||
const tagline = hero?.tagline ?? data.description ?? '';
|
||||
const eyebrow = hero?.eyebrow ?? 'Open source · PHP 8 · MMVC';
|
||||
const actions = Array.isArray(hero?.actions) ? hero.actions : [];
|
||||
|
||||
const shouldRender = !!hero;
|
||||
---
|
||||
|
||||
{shouldRender && (
|
||||
<section class="hero" aria-labelledby="hero-h">
|
||||
<canvas id="constellation" aria-hidden="true"></canvas>
|
||||
|
||||
<div class="container hero-inner">
|
||||
{eyebrow && (
|
||||
<div class="hero-meta">
|
||||
<span class="eyebrow">{eyebrow}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{title && (
|
||||
<h1 id="hero-h" class="hero-headline" set:html={title} />
|
||||
)}
|
||||
|
||||
{tagline && (
|
||||
<p class="hero-sub" set:html={tagline} />
|
||||
)}
|
||||
|
||||
{actions.length > 0 && (
|
||||
<div class="hero-ctas">
|
||||
{actions.map((a: any, i: number) => (
|
||||
<a
|
||||
href={a.link}
|
||||
class={`hero-link${i === 0 ? '' : ' muted'}`}
|
||||
>
|
||||
{a.text} <span class="arrow" aria-hidden="true">→</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div class="hero-telemetry" aria-hidden="true">
|
||||
<div class="tele-cell"><span class="pulse"></span> <strong>NIBIRU SYSTEM</strong> · live</div>
|
||||
<div class="tele-cell">FRAMEWORK <strong>v0.9.2</strong></div>
|
||||
<div class="tele-cell">MODULES <strong>4 in orbit</strong></div>
|
||||
<div class="tele-cell">CONTROLLER <strong>idle</strong></div>
|
||||
<div class="tele-cell">MIT</div>
|
||||
</div>
|
||||
|
||||
{/* Tooltip placeholder for hero module-hover labels — positioned by nibiru-scene.js */}
|
||||
<div class="module-tip" id="moduleTip" role="tooltip" aria-hidden="true">
|
||||
<span class="name"></span>
|
||||
<span class="sig"></span>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<style is:global>
|
||||
.hero {
|
||||
position: relative;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
isolation: isolate;
|
||||
/* Wider than the docs content — full viewport bleed */
|
||||
width: 100vw;
|
||||
margin-left: 50%;
|
||||
margin-right: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: var(--nibiru-gradient-nebula);
|
||||
color: var(--nibiru-star);
|
||||
}
|
||||
#constellation {
|
||||
position: absolute; inset: 0;
|
||||
width: 100%; height: 100%;
|
||||
z-index: 0;
|
||||
}
|
||||
/* Bottom fade so the headline reads cleanly against the field below */
|
||||
.hero::after {
|
||||
content: '';
|
||||
position: absolute; left: 0; right: 0; bottom: 0; height: 50%;
|
||||
background: linear-gradient(to top, var(--nibiru-space) 0%, rgba(10, 4, 20, 0.7) 30%, transparent 100%);
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
}
|
||||
.hero-inner {
|
||||
position: relative; z-index: 2;
|
||||
max-width: var(--nibiru-container, 1280px);
|
||||
margin: 0 auto;
|
||||
padding: 220px 32px 140px;
|
||||
width: 100%;
|
||||
}
|
||||
.hero-meta {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 36px;
|
||||
}
|
||||
.hero-headline {
|
||||
font-family: var(--font-display);
|
||||
font-size: var(--nibiru-text-hero, clamp(3.5rem, 7vw + 0.5rem, 7.75rem));
|
||||
line-height: 0.95;
|
||||
letter-spacing: var(--nibiru-tracking-display, -0.04em);
|
||||
font-weight: 400;
|
||||
margin: 0;
|
||||
max-width: 14ch;
|
||||
color: var(--nibiru-star);
|
||||
}
|
||||
.hero-headline em {
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
background: var(--nibiru-gradient-headline);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
color: transparent;
|
||||
}
|
||||
.hero-headline .period {
|
||||
color: var(--nibiru-nebula-amber);
|
||||
font-weight: 500;
|
||||
}
|
||||
.hero-sub {
|
||||
margin: 40px 0 0;
|
||||
font-size: 1.125rem;
|
||||
color: rgba(244, 238, 219, 0.72);
|
||||
max-width: 48ch;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.hero-ctas {
|
||||
display: flex;
|
||||
gap: 28px;
|
||||
align-items: center;
|
||||
margin-top: 48px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.hero-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
color: var(--nibiru-star);
|
||||
font-size: 1rem;
|
||||
padding: 4px 0;
|
||||
border-bottom: 1px solid rgba(244, 238, 219, 0.4);
|
||||
text-decoration: none;
|
||||
transition: gap 200ms ease, border-color 200ms ease, color 200ms ease;
|
||||
}
|
||||
.hero-link:hover {
|
||||
gap: 18px;
|
||||
border-bottom-color: var(--nibiru-star);
|
||||
}
|
||||
.hero-link .arrow {
|
||||
font-family: var(--font-mono);
|
||||
transition: transform 200ms ease;
|
||||
}
|
||||
.hero-link:hover .arrow { transform: translateX(4px); }
|
||||
.hero-link.muted {
|
||||
color: rgba(244, 238, 219, 0.6);
|
||||
border-bottom-color: rgba(244, 238, 219, 0.18);
|
||||
}
|
||||
.hero-link.muted:hover { color: var(--nibiru-star); }
|
||||
|
||||
.hero-telemetry {
|
||||
position: absolute;
|
||||
bottom: 32px; left: 0; right: 0;
|
||||
z-index: 3;
|
||||
display: flex;
|
||||
gap: 48px;
|
||||
justify-content: space-between;
|
||||
padding: 0 32px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.69rem;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--nibiru-muted);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.hero-telemetry .tele-cell strong {
|
||||
color: rgba(244, 238, 219, 0.85);
|
||||
font-weight: 500;
|
||||
}
|
||||
.hero-telemetry .tele-cell .pulse {
|
||||
width: 6px; height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--nibiru-success, #7ad6a3);
|
||||
box-shadow: 0 0 8px rgba(122, 214, 163, 0.7);
|
||||
animation: tele-pulse 2.4s ease-in-out infinite;
|
||||
}
|
||||
@media (max-width: 960px) {
|
||||
.hero-headline { font-size: clamp(3rem, 12vw, 5rem); }
|
||||
.hero-telemetry { display: none; }
|
||||
.hero-inner { padding: 160px 24px 100px; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
#constellation { display: none; }
|
||||
}
|
||||
</style>
|
||||
|
||||
563
docs/src/components/GalaxyHero.legacy.webgl.astro
Normal file
563
docs/src/components/GalaxyHero.legacy.webgl.astro
Normal file
@@ -0,0 +1,563 @@
|
||||
---
|
||||
const { data } = Astro.locals.starlightRoute.entry;
|
||||
const hero = data.hero;
|
||||
|
||||
const title = hero?.title ?? data.title ?? '';
|
||||
const tagline = hero?.tagline ?? data.description ?? '';
|
||||
const eyebrow = hero?.eyebrow ?? 'Modular MMVC PHP framework';
|
||||
const actions = Array.isArray(hero?.actions) ? hero.actions : [];
|
||||
|
||||
const shouldRender = !!hero;
|
||||
---
|
||||
|
||||
{shouldRender && (
|
||||
<section class="galaxy-hero">
|
||||
<canvas class="galaxy-hero__canvas" id="galaxy-canvas" aria-hidden="true"></canvas>
|
||||
<div class="galaxy-hero__veil" aria-hidden="true"></div>
|
||||
|
||||
<span class="galaxy-hero__number" aria-hidden="true">01</span>
|
||||
|
||||
<div class="galaxy-hero__copy">
|
||||
{eyebrow && <p class="galaxy-hero__eyebrow">{eyebrow}</p>}
|
||||
{title && <h1 class="galaxy-hero__title" set:html={title} />}
|
||||
{tagline && <p class="galaxy-hero__lede" set:html={tagline} />}
|
||||
|
||||
{actions.length > 0 && (
|
||||
<div class="galaxy-hero__cta">
|
||||
{actions.map((a: any) => (
|
||||
<a
|
||||
href={a.link}
|
||||
class={`galaxy-button ${
|
||||
a.variant === 'minimal' || a.variant === 'secondary'
|
||||
? 'galaxy-button--ghost'
|
||||
: 'galaxy-button--primary'
|
||||
}`}
|
||||
>
|
||||
<span>{a.text}</span>
|
||||
{a.icon === 'right-arrow' && <span class="galaxy-button__arrow" aria-hidden="true">→</span>}
|
||||
{a.icon === 'external' && <span class="galaxy-button__arrow" aria-hidden="true">↗</span>}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<style is:global>
|
||||
/* === HERO container — FULL-BLEED, framed, centered === */
|
||||
.galaxy-hero {
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
width: 100vw;
|
||||
margin-left: 50%;
|
||||
margin-right: 50%;
|
||||
transform: translateX(-50%);
|
||||
padding: clamp(3rem, 6vh, 6rem) clamp(1.5rem, 4vw, 4rem) clamp(4rem, 9vh, 8rem);
|
||||
min-height: calc(100vh - var(--sl-nav-height, 5.2rem));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
overflow: hidden;
|
||||
color: #f5f1e8;
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
#07041a 0%,
|
||||
#0c0728 30%,
|
||||
#150a3c 60%,
|
||||
#2c1a52 85%,
|
||||
#4a2f6c 100%
|
||||
);
|
||||
}
|
||||
|
||||
/* Inset frame — a thin paper-coloured rule outlining the cosmic stage,
|
||||
* like a framed astronomy photograph. Doubles as a scrim that darkens
|
||||
* the left third so the copy reads cleanly against the spiral arms. */
|
||||
.galaxy-hero__veil {
|
||||
position: absolute;
|
||||
inset: clamp(10px, 1.2vw, 22px);
|
||||
z-index: 1;
|
||||
pointer-events: none;
|
||||
border: 1px solid rgba(196, 181, 253, 0.28);
|
||||
border-radius: 6px;
|
||||
box-shadow:
|
||||
inset 0 0 160px rgba(0, 0, 0, 0.55),
|
||||
inset 0 0 0 1px rgba(7, 4, 26, 0.6);
|
||||
background:
|
||||
/* dark scrim on the left half so text reads through */
|
||||
linear-gradient(
|
||||
90deg,
|
||||
rgba(7, 4, 26, 0.78) 0%,
|
||||
rgba(7, 4, 26, 0.55) 22%,
|
||||
rgba(7, 4, 26, 0.18) 42%,
|
||||
rgba(7, 4, 26, 0) 60%
|
||||
),
|
||||
/* faint amethyst glow toward the galactic core */
|
||||
radial-gradient(ellipse at 62% 50%, rgba(124, 112, 171, 0.10) 0%, transparent 60%);
|
||||
}
|
||||
|
||||
/* Soft fade-to-paper at the bottom. Bezier-eased gradient stops give
|
||||
* a perceptually-smooth transition: most of the alpha change happens
|
||||
* in the middle of the fade region, none at the extremes. The bottom
|
||||
* of this fade exactly matches the body's cream colour — no seam. */
|
||||
.galaxy-hero::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: auto 0 -1px 0;
|
||||
height: 38vh;
|
||||
min-height: 320px;
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
rgba(245, 241, 232, 0) 0%,
|
||||
rgba(245, 241, 232, 0.01) 12%,
|
||||
rgba(245, 241, 232, 0.04) 25%,
|
||||
rgba(245, 241, 232, 0.10) 38%,
|
||||
rgba(245, 241, 232, 0.22) 50%,
|
||||
rgba(245, 241, 232, 0.40) 62%,
|
||||
rgba(245, 241, 232, 0.62) 74%,
|
||||
rgba(245, 241, 232, 0.82) 86%,
|
||||
rgba(245, 241, 232, 0.95) 94%,
|
||||
rgba(245, 241, 232, 1) 100%
|
||||
);
|
||||
pointer-events: none;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] .galaxy-hero::after {
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
rgba(24, 20, 40, 0) 0%,
|
||||
rgba(24, 20, 40, 0.10) 30%,
|
||||
rgba(24, 20, 40, 0.40) 60%,
|
||||
rgba(24, 20, 40, 0.80) 85%,
|
||||
rgba(24, 20, 40, 1) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.galaxy-hero__canvas {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 0;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Subtle vignette + atmospheric haze on top of the canvas */
|
||||
.galaxy-hero__veil {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 1;
|
||||
pointer-events: none;
|
||||
background:
|
||||
radial-gradient(ellipse at 65% 50%, rgba(124, 112, 171, 0.12) 0%, transparent 60%),
|
||||
radial-gradient(ellipse at 100% 100%, rgba(0, 0, 0, 0.55) 0%, transparent 60%);
|
||||
}
|
||||
|
||||
/* Copy floats over the galaxy on the left third of the framed stage.
|
||||
* The galaxy itself stays visible behind the text via low-opacity backdrop. */
|
||||
.galaxy-hero__copy {
|
||||
position: relative;
|
||||
z-index: 3;
|
||||
max-width: 32rem;
|
||||
margin-left: max(2rem, calc((100vw - var(--sl-content-width, 50rem)) / 2));
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.galaxy-hero__copy {
|
||||
margin-left: 1.5rem;
|
||||
margin-right: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.galaxy-hero__number {
|
||||
position: absolute;
|
||||
right: clamp(2rem, 5vw, 6rem);
|
||||
bottom: clamp(2rem, 6vh, 5rem);
|
||||
font-family: var(--font-display, 'Bricolage Grotesque', sans-serif);
|
||||
font-variation-settings: 'opsz' 96, 'wght' 200;
|
||||
font-size: clamp(8rem, 14vw, 16rem);
|
||||
line-height: 1;
|
||||
color: rgba(196, 181, 253, 0.10);
|
||||
letter-spacing: -0.06em;
|
||||
pointer-events: none;
|
||||
z-index: 2;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.galaxy-hero__eyebrow {
|
||||
font-family: var(--font-display, 'Bricolage Grotesque', sans-serif);
|
||||
font-size: 0.72rem;
|
||||
letter-spacing: 0.22em;
|
||||
text-transform: uppercase;
|
||||
color: #fbbf24;
|
||||
margin: 0 0 1.6rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.7rem;
|
||||
}
|
||||
.galaxy-hero__eyebrow::before {
|
||||
content: '';
|
||||
display: inline-block;
|
||||
width: 24px;
|
||||
height: 1px;
|
||||
background: #fbbf24;
|
||||
}
|
||||
|
||||
.galaxy-hero__title {
|
||||
font-family: var(--font-display, 'Bricolage Grotesque', sans-serif);
|
||||
font-variation-settings: 'opsz' 96, 'wght' 600;
|
||||
font-size: clamp(2.6rem, 1.8rem + 4vw, 4.8rem);
|
||||
line-height: 0.96;
|
||||
letter-spacing: -0.04em;
|
||||
margin: 0 0 1.4rem;
|
||||
color: #ffffff;
|
||||
max-width: 16ch;
|
||||
text-shadow: 0 2px 24px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.galaxy-hero__title em {
|
||||
font-style: normal;
|
||||
color: #c4b5fd;
|
||||
position: relative;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.galaxy-hero__title em::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0.05em;
|
||||
height: 0.18em;
|
||||
background: #fbbf24;
|
||||
opacity: 0.55;
|
||||
z-index: -1;
|
||||
box-shadow: 0 0 24px rgba(251, 191, 36, 0.5);
|
||||
}
|
||||
|
||||
.galaxy-hero__lede {
|
||||
font-family: var(--font-text, 'Bricolage Grotesque', sans-serif);
|
||||
font-variation-settings: 'opsz' 24, 'wght' 400;
|
||||
font-size: clamp(1.05rem, 0.9rem + 0.3vw, 1.22rem);
|
||||
line-height: 1.55;
|
||||
letter-spacing: -0.012em;
|
||||
color: rgba(245, 241, 232, 0.85);
|
||||
max-width: 36ch;
|
||||
margin: 0 0 2rem;
|
||||
text-shadow: 0 1px 12px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.galaxy-hero__lede strong {
|
||||
color: #ffffff;
|
||||
font-variation-settings: 'opsz' 24, 'wght' 600;
|
||||
}
|
||||
|
||||
.galaxy-hero__cta {
|
||||
display: flex;
|
||||
gap: 0.7rem;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.galaxy-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.7rem 1.2rem;
|
||||
font-family: var(--font-text, 'Bricolage Grotesque', sans-serif);
|
||||
font-variation-settings: 'opsz' 14, 'wght' 600;
|
||||
font-size: 0.92rem;
|
||||
letter-spacing: -0.005em;
|
||||
border-radius: 3px;
|
||||
text-decoration: none;
|
||||
transition: transform 200ms cubic-bezier(0.2, 0.7, 0.2, 1),
|
||||
box-shadow 200ms,
|
||||
background-color 200ms;
|
||||
}
|
||||
|
||||
.galaxy-button--primary {
|
||||
background: linear-gradient(135deg, #fbbf24, #fde68a);
|
||||
color: #1f1b2e;
|
||||
border: 1px solid rgba(251, 191, 36, 0.6);
|
||||
box-shadow: 0 1px 0 rgba(251, 191, 36, 0.4),
|
||||
0 0 32px rgba(251, 191, 36, 0.25);
|
||||
}
|
||||
.galaxy-button--primary:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 8px 32px -4px rgba(251, 191, 36, 0.5);
|
||||
}
|
||||
|
||||
.galaxy-button--ghost {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: #f5f1e8;
|
||||
border: 1px solid rgba(245, 241, 232, 0.3);
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
.galaxy-button--ghost:hover {
|
||||
border-color: #c4b5fd;
|
||||
color: #c4b5fd;
|
||||
}
|
||||
|
||||
.galaxy-button .galaxy-button__arrow {
|
||||
transition: transform 240ms cubic-bezier(0.2, 0.7, 0.2, 1);
|
||||
}
|
||||
.galaxy-button:hover .galaxy-button__arrow {
|
||||
transform: translateX(3px);
|
||||
}
|
||||
|
||||
/* Lotus mark anchored on the right — floating in front of the galaxy */
|
||||
.galaxy-hero__mark {
|
||||
width: clamp(150px, 22vw, 290px);
|
||||
height: auto;
|
||||
filter:
|
||||
drop-shadow(0 0 30px rgba(196, 181, 253, 0.45))
|
||||
drop-shadow(0 0 60px rgba(125, 183, 220, 0.25))
|
||||
brightness(1.15);
|
||||
animation: galaxy-breathe 14s ease-in-out infinite;
|
||||
transition: filter 400ms ease;
|
||||
}
|
||||
|
||||
@keyframes galaxy-breathe {
|
||||
0%, 100% { transform: translateY(0) rotate(0deg); }
|
||||
50% { transform: translateY(-8px) rotate(0.8deg); }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.galaxy-hero__mark { animation: none; }
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.galaxy-hero__mark {
|
||||
width: 130px;
|
||||
opacity: 0.85;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script is:inline>
|
||||
(function () {
|
||||
const canvas = document.getElementById('galaxy-canvas');
|
||||
if (!canvas) return;
|
||||
|
||||
const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
|
||||
const gl = canvas.getContext('webgl2', {
|
||||
alpha: true,
|
||||
antialias: true,
|
||||
premultipliedAlpha: false,
|
||||
}) || canvas.getContext('webgl', { alpha: true, antialias: true });
|
||||
if (!gl) return;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Particle generation — a tilted spiral galaxy with 4 arms.
|
||||
// ---------------------------------------------------------------------------
|
||||
const PARTICLE_COUNT = 14000;
|
||||
|
||||
const positions = new Float32Array(PARTICLE_COUNT * 3);
|
||||
const colors = new Float32Array(PARTICLE_COUNT * 4);
|
||||
const sizes = new Float32Array(PARTICLE_COUNT);
|
||||
const seeds = new Float32Array(PARTICLE_COUNT);
|
||||
|
||||
const ARMS = 4;
|
||||
const ARM_SPREAD = 0.55;
|
||||
|
||||
for (let i = 0; i < PARTICLE_COUNT; i++) {
|
||||
// Bias toward the centre — pow(rand, 0.55) clusters values near zero.
|
||||
const r = Math.pow(Math.random(), 0.55) * 1.0;
|
||||
const arm = Math.floor(Math.random() * ARMS);
|
||||
const baseTheta = (arm * (Math.PI * 2) / ARMS) + Math.log(r + 0.05) * 5.0;
|
||||
const theta = baseTheta + (Math.random() - 0.5) * ARM_SPREAD * (1 - r);
|
||||
|
||||
// Thin disk with slight thickness toward the centre.
|
||||
const thickness = 0.04 + (1 - r) * 0.10;
|
||||
const y = (Math.random() - 0.5) * thickness;
|
||||
|
||||
positions[i * 3 + 0] = r * Math.cos(theta);
|
||||
positions[i * 3 + 1] = y;
|
||||
positions[i * 3 + 2] = r * Math.sin(theta);
|
||||
|
||||
// Colour by radius — gold core → violet → cyan → pale lavender outer.
|
||||
let cr, cg, cb;
|
||||
if (r < 0.12) {
|
||||
// Hot bright core
|
||||
cr = 1.00; cg = 0.93; cb = 0.70;
|
||||
} else if (r < 0.32) {
|
||||
cr = 0.99; cg = 0.75; cb = 0.42; // marduk gold
|
||||
} else if (r < 0.55) {
|
||||
cr = 0.66; cg = 0.51; cb = 0.96; // amethyst
|
||||
} else if (r < 0.80) {
|
||||
cr = 0.49; cg = 0.72; cb = 0.86; // skyfall blue
|
||||
} else {
|
||||
cr = 0.92; cg = 0.90; cb = 0.95; // pale lavender
|
||||
}
|
||||
|
||||
// Slight per-particle colour wobble for richness
|
||||
const jitter = (Math.random() - 0.5) * 0.10;
|
||||
cr = Math.max(0, Math.min(1, cr + jitter));
|
||||
cg = Math.max(0, Math.min(1, cg + jitter));
|
||||
cb = Math.max(0, Math.min(1, cb + jitter));
|
||||
|
||||
colors[i * 4 + 0] = cr;
|
||||
colors[i * 4 + 1] = cg;
|
||||
colors[i * 4 + 2] = cb;
|
||||
// Higher base alpha — the additive blend on a near-black canvas needs
|
||||
// brighter contribution per-particle to make the spiral structure pop.
|
||||
colors[i * 4 + 3] = 0.65 + Math.random() * 0.35;
|
||||
|
||||
sizes[i] = 1.6 + Math.random() * 3.0;
|
||||
if (r < 0.10) sizes[i] *= 2.6; // hot core particles much bigger
|
||||
else if (r < 0.30) sizes[i] *= 1.4; // inner arms emphasized
|
||||
if (r > 0.85) sizes[i] *= 0.85; // outer faint dust slightly smaller
|
||||
|
||||
seeds[i] = Math.random();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shaders
|
||||
// ---------------------------------------------------------------------------
|
||||
const vsSrc = `
|
||||
precision mediump float;
|
||||
attribute vec3 aPos;
|
||||
attribute vec4 aCol;
|
||||
attribute float aSize;
|
||||
attribute float aSeed;
|
||||
uniform float uTime;
|
||||
uniform float uRatio;
|
||||
uniform float uPixelRatio;
|
||||
varying vec4 vCol;
|
||||
|
||||
void main() {
|
||||
// Inner particles rotate faster than outer (like a real galaxy).
|
||||
// Slow, contemplative pace: outer arms ~ 90s/revolution.
|
||||
float r = length(aPos.xz);
|
||||
float speed = 0.28 / (r + 0.12);
|
||||
float angle = uTime * speed * 0.024;
|
||||
float c = cos(angle), s = sin(angle);
|
||||
vec3 p = vec3(c * aPos.x - s * aPos.z, aPos.y, s * aPos.x + c * aPos.z);
|
||||
|
||||
// 3/4 perspective view — disk tilted at ~50° so the spiral is clearly
|
||||
// visible as an ellipse, not a perfect circle. Less face-on than 1.15,
|
||||
// less edge-on than 0.42; sweet spot for editorial composition.
|
||||
float tx = 0.85;
|
||||
float cx = cos(tx), sx = sin(tx);
|
||||
p = vec3(p.x, cx * p.y - sx * p.z, sx * p.y + cx * p.z);
|
||||
|
||||
// Subtle camera drift — Lissajous oscillation, very slow.
|
||||
vec2 drift = vec2(sin(uTime * 0.018) * 0.04, cos(uTime * 0.013) * 0.03);
|
||||
|
||||
// Centred. scale 1.1 so the disk fills the framed stage comfortably
|
||||
// without clipping at top/bottom.
|
||||
float scale = 1.1;
|
||||
vec2 screen = vec2(p.x * scale + drift.x, p.y * 1.0 * scale + drift.y);
|
||||
screen.x /= uRatio;
|
||||
|
||||
gl_Position = vec4(screen, p.z * 0.5, 1.0);
|
||||
|
||||
// Twinkle: subtle, only ±10% size modulation, never dims below baseline.
|
||||
float tw = 0.95 + 0.05 * sin(uTime * 0.6 + aSeed * 6.28);
|
||||
gl_PointSize = aSize * uPixelRatio * tw;
|
||||
|
||||
vCol = aCol;
|
||||
}
|
||||
`;
|
||||
|
||||
const fsSrc = `
|
||||
precision mediump float;
|
||||
varying vec4 vCol;
|
||||
|
||||
void main() {
|
||||
vec2 uv = gl_PointCoord - 0.5;
|
||||
float d = length(uv);
|
||||
// Soft round particle with brighter halo; widened halo radius so
|
||||
// particles bleed into each other and form visible dust lanes.
|
||||
float core = 1.0 - smoothstep(0.0, 0.22, d);
|
||||
float halo = 1.0 - smoothstep(0.22, 0.50, d);
|
||||
float alpha = core + halo * 0.75;
|
||||
// Add brightness boost in the core for the bloom feel.
|
||||
gl_FragColor = vec4(vCol.rgb * (1.0 + core * 1.0), vCol.a * alpha);
|
||||
}
|
||||
`;
|
||||
|
||||
function compile(type, src) {
|
||||
const sh = gl.createShader(type);
|
||||
gl.shaderSource(sh, src);
|
||||
gl.compileShader(sh);
|
||||
if (!gl.getShaderParameter(sh, gl.COMPILE_STATUS)) {
|
||||
console.error('shader compile:', gl.getShaderInfoLog(sh));
|
||||
gl.deleteShader(sh);
|
||||
return null;
|
||||
}
|
||||
return sh;
|
||||
}
|
||||
|
||||
const vs = compile(gl.VERTEX_SHADER, vsSrc);
|
||||
const fs = compile(gl.FRAGMENT_SHADER, fsSrc);
|
||||
if (!vs || !fs) return;
|
||||
|
||||
const prog = gl.createProgram();
|
||||
gl.attachShader(prog, vs);
|
||||
gl.attachShader(prog, fs);
|
||||
gl.linkProgram(prog);
|
||||
if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) {
|
||||
console.error('program link:', gl.getProgramInfoLog(prog));
|
||||
return;
|
||||
}
|
||||
gl.useProgram(prog);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Buffers
|
||||
// ---------------------------------------------------------------------------
|
||||
function buf(data, attrName, size) {
|
||||
const b = gl.createBuffer();
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, b);
|
||||
gl.bufferData(gl.ARRAY_BUFFER, data, gl.STATIC_DRAW);
|
||||
const loc = gl.getAttribLocation(prog, attrName);
|
||||
gl.enableVertexAttribArray(loc);
|
||||
gl.vertexAttribPointer(loc, size, gl.FLOAT, false, 0, 0);
|
||||
}
|
||||
|
||||
buf(positions, 'aPos', 3);
|
||||
buf(colors, 'aCol', 4);
|
||||
buf(sizes, 'aSize', 1);
|
||||
buf(seeds, 'aSeed', 1);
|
||||
|
||||
const uTime = gl.getUniformLocation(prog, 'uTime');
|
||||
const uRatio = gl.getUniformLocation(prog, 'uRatio');
|
||||
const uPx = gl.getUniformLocation(prog, 'uPixelRatio');
|
||||
|
||||
gl.enable(gl.BLEND);
|
||||
gl.blendFunc(gl.SRC_ALPHA, gl.ONE); // additive
|
||||
gl.disable(gl.DEPTH_TEST);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Resize + animation loop
|
||||
// ---------------------------------------------------------------------------
|
||||
function resize() {
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
canvas.width = Math.max(2, Math.floor(rect.width * dpr));
|
||||
canvas.height = Math.max(2, Math.floor(rect.height * dpr));
|
||||
gl.viewport(0, 0, canvas.width, canvas.height);
|
||||
gl.uniform1f(uRatio, canvas.width / canvas.height);
|
||||
gl.uniform1f(uPx, dpr);
|
||||
}
|
||||
resize();
|
||||
window.addEventListener('resize', resize);
|
||||
|
||||
let start = performance.now();
|
||||
function frame(now) {
|
||||
const t = (now - start) * 0.001;
|
||||
gl.clearColor(0, 0, 0, 0);
|
||||
gl.clear(gl.COLOR_BUFFER_BIT);
|
||||
gl.uniform1f(uTime, t);
|
||||
gl.drawArrays(gl.POINTS, 0, PARTICLE_COUNT);
|
||||
if (!reduced) requestAnimationFrame(frame);
|
||||
}
|
||||
requestAnimationFrame(frame);
|
||||
|
||||
// Pause when tab is hidden
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (!document.hidden && !reduced) requestAnimationFrame(frame);
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
154
docs/src/components/LandingFooter.astro
Normal file
154
docs/src/components/LandingFooter.astro
Normal file
@@ -0,0 +1,154 @@
|
||||
---
|
||||
/**
|
||||
* LandingFooter — mockup-faithful footer for the splash page.
|
||||
*
|
||||
* • 4-column grid: brand · Framework · Models · Community
|
||||
* • Subtle star wash background (footer-canvas, vanilla canvas2d)
|
||||
* • Bottom strip: "© 2026 Nibiru · Apache 2.0" + "Built in orbit · v0.9.2"
|
||||
*
|
||||
* The mockup's `id="docs"` anchor is preserved so in-page nav `<a href="#docs">`
|
||||
* still scrolls here.
|
||||
*/
|
||||
---
|
||||
<footer class="footer" id="docs">
|
||||
<canvas id="footer-canvas" aria-hidden="true"></canvas>
|
||||
<div class="container footer-inner">
|
||||
<div class="footer-brand">
|
||||
<div class="brand">
|
||||
<img class="brand-mark" src="/img/nibiru-logo.png" alt="" style="height: 30px; width: auto;" />
|
||||
<span class="brand-name">Nibiru<em></em></span>
|
||||
</div>
|
||||
<p>An AI-native MMVC PHP framework. Open source. Open weights when they ship. Built to compose.</p>
|
||||
</div>
|
||||
<div class="footer-col">
|
||||
<h5>Framework</h5>
|
||||
<ul>
|
||||
<li><a href="#mmvc">MMVC pattern</a></li>
|
||||
<li><a href="#code">Quick start</a></li>
|
||||
<li><a href="/en/core/modules/">Modules</a></li>
|
||||
<li><a href="/en/start/what-is-nibiru/">Reference</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="footer-col">
|
||||
<h5>AI module</h5>
|
||||
<ul>
|
||||
<li><a href="/en/ai/module/overview/">Overview</a></li>
|
||||
<li><a href="/en/ai/module/rag/">RAG plugin</a></li>
|
||||
<li><a href="/en/ai/module/agent/">Agent plugin</a></li>
|
||||
<li><a href="/en/ai/oracle/">The Oracle</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="footer-col">
|
||||
<h5>Community</h5>
|
||||
<ul>
|
||||
<li><a href="https://github.com/alllinux/Nibiru">GitHub</a></li>
|
||||
<li><a href="/en/showcase/projects/">Showcase</a></li>
|
||||
<li><a href="/en/why-nibiru/">Why Nibiru</a></li>
|
||||
<li><a href="/en/ai/roadmap/">Roadmap</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="container footer-bottom">
|
||||
<span>© 2026 Nibiru · MIT licensed</span>
|
||||
<span>Built in orbit · v0.9.2</span>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<style is:global>
|
||||
.footer {
|
||||
position: relative;
|
||||
padding: 100px 0 60px;
|
||||
background: var(--nibiru-space);
|
||||
border-top: 1px solid var(--nibiru-line);
|
||||
overflow: hidden;
|
||||
color: var(--nibiru-star);
|
||||
/* Break out of any centring container — the footer is full-bleed */
|
||||
width: 100vw;
|
||||
margin-left: 50%;
|
||||
margin-right: 50%;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
.footer #footer-canvas {
|
||||
position: absolute; inset: 0;
|
||||
width: 100%; height: 100%;
|
||||
opacity: 0.4;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
.footer-inner {
|
||||
position: relative; z-index: 2;
|
||||
display: grid;
|
||||
grid-template-columns: 1.4fr 1fr 1fr 1fr;
|
||||
gap: 48px;
|
||||
max-width: var(--nibiru-container, 1280px);
|
||||
margin: 0 auto;
|
||||
padding: 0 32px;
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.footer-inner { grid-template-columns: 1fr 1fr; }
|
||||
}
|
||||
.footer-brand .brand {
|
||||
margin-bottom: 16px;
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
text-decoration: none;
|
||||
}
|
||||
.footer-brand .brand-name {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 500;
|
||||
font-size: 18px;
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--nibiru-star);
|
||||
}
|
||||
.footer-brand .brand-name em { font-style: normal; font-weight: 300; }
|
||||
.footer-brand p {
|
||||
font-size: 14px;
|
||||
color: rgba(244, 238, 219, 0.55);
|
||||
max-width: 36ch;
|
||||
line-height: 1.55;
|
||||
margin: 12px 0 0;
|
||||
}
|
||||
.footer-col h5 {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
color: var(--nibiru-muted);
|
||||
margin: 0 0 18px;
|
||||
font-weight: 400;
|
||||
}
|
||||
.footer-col ul {
|
||||
list-style: none;
|
||||
padding: 0; margin: 0;
|
||||
display: flex; flex-direction: column; gap: 12px;
|
||||
}
|
||||
.footer-col a {
|
||||
font-size: 14px;
|
||||
color: rgba(244, 238, 219, 0.7);
|
||||
text-decoration: none;
|
||||
transition: color 160ms ease;
|
||||
}
|
||||
.footer-col a:hover { color: var(--nibiru-star); }
|
||||
.footer-bottom {
|
||||
margin-top: 80px;
|
||||
padding-top: 24px;
|
||||
border-top: 1px solid var(--nibiru-line);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--nibiru-muted);
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
max-width: var(--nibiru-container, 1280px);
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
padding-left: 32px;
|
||||
padding-right: 32px;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
</style>
|
||||
|
||||
22
docs/src/components/LandingScripts.astro
Normal file
22
docs/src/components/LandingScripts.astro
Normal file
@@ -0,0 +1,22 @@
|
||||
---
|
||||
/**
|
||||
* LandingScripts — loads the original mockup three.js + nibiru-scene.js
|
||||
* verbatim from /js/. Drop these at the END of the splash page so every
|
||||
* element they reference (#nav, #constellation, #mmvc-canvas, #mcBody,
|
||||
* #launchBody, #launch-canvas, #footer-canvas, #moduleTip, #toTop, …)
|
||||
* is already in the DOM when the scripts execute.
|
||||
*
|
||||
* The two files are extracted verbatim from the bundler-wrapped mockup
|
||||
* (/Nibiru/nibiru-startpage.html) — three.js (~600KB) and the scene code
|
||||
* (~38KB). They are mirrored to public/js/ on every build via
|
||||
* scripts/sync-design-system.mjs.
|
||||
*
|
||||
* Mission Control chat posts to /api/oracle (Astro endpoint at
|
||||
* src/pages/api/oracle.ts) which routes to Ollama via providers.mjs.
|
||||
* Production points the Ollama base URL at api.neuronetz.ai. The fallback
|
||||
* "Link interrupted. Re-establishing controller…" message now only fires
|
||||
* if the fetch itself fails or the endpoint returns non-2xx.
|
||||
*/
|
||||
---
|
||||
<script is:inline src="/js/three.min.js"></script>
|
||||
<script is:inline src="/js/nibiru-scene.js"></script>
|
||||
37
docs/src/components/LaunchSequence.astro
Normal file
37
docs/src/components/LaunchSequence.astro
Normal file
@@ -0,0 +1,37 @@
|
||||
---
|
||||
/**
|
||||
* Launch Sequence — code on the left, simulated system canvas on the right.
|
||||
* Hit "▶ Launch" to type the code line-by-line; the canvas reacts by
|
||||
* spinning up modules and updating the stats.
|
||||
*/
|
||||
---
|
||||
<section class="section" id="code" aria-labelledby="code-h">
|
||||
<div class="container">
|
||||
<div class="section-head">
|
||||
<span class="eyebrow">Launch sequence · 03</span>
|
||||
<h2 id="code-h" class="section-title">Eleven lines, <span class="accent">one orbit.</span></h2>
|
||||
<p class="section-sub">Code on the left, system on the right. Hit launch and watch the modules slide into place as the controller wires them up.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="launch-grid">
|
||||
<div class="launch-code">
|
||||
<div class="launch-code-head">
|
||||
<span class="file">agent.php</span>
|
||||
<button class="play" id="launchPlay" type="button">▶ Launch</button>
|
||||
</div>
|
||||
<pre class="launch-code-body" id="launchBody"></pre>
|
||||
</div>
|
||||
|
||||
<div class="launch-system">
|
||||
<canvas id="launch-canvas" aria-hidden="true"></canvas>
|
||||
<div class="legend">// nibiru system · live</div>
|
||||
<div class="stats" id="launchStats">
|
||||
<span>Modules <strong id="lsModules">0</strong></span>
|
||||
<span>Tokens <strong id="lsTokens">0</strong></span>
|
||||
<span>Latency <strong id="lsLatency">— ms</strong></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
52
docs/src/components/MissionControl.astro
Normal file
52
docs/src/components/MissionControl.astro
Normal file
@@ -0,0 +1,52 @@
|
||||
---
|
||||
/**
|
||||
* Mission Control — terminal-style mock chat with simulated streaming.
|
||||
* Wires to /api/oracle if available, otherwise streams a canned response
|
||||
* from a small response table. Fully client-rendered, no JS frameworks.
|
||||
*/
|
||||
---
|
||||
<section class="section" id="chat" aria-labelledby="chat-h">
|
||||
<div class="container">
|
||||
<div class="section-head">
|
||||
<span class="eyebrow">Live system · 02</span>
|
||||
<h2 id="chat-h" class="section-title">Mission <span class="accent">Control.</span></h2>
|
||||
<p class="section-sub">A working playground. Same MMVC controller — just with the dashboard turned on so you can see the modules light up.</p>
|
||||
</div>
|
||||
|
||||
<div class="mc-frame" id="mcFrame">
|
||||
<div class="mc-header">
|
||||
<div class="mc-id"><span class="dot"></span><span>NIBIRU/MC · session 7c4a</span></div>
|
||||
<div class="mc-tele" id="mcTele">
|
||||
<span>MODEL <strong>nibiru-base-7B</strong></span>
|
||||
<span>LATENCY <strong id="mcLatency">—</strong></span>
|
||||
<span>TOKENS <strong id="mcTokens">0</strong></span>
|
||||
</div>
|
||||
<div class="mc-actions">
|
||||
<button title="Reset" id="mcReset" type="button" aria-label="Reset session">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
|
||||
<path d="M3 12a9 9 0 1 0 3-6.7L3 8"/><path d="M3 3v5h5"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mc-body" id="mcBody">
|
||||
<div class="mc-line sys">› establishing link to nibiru-base-7B …</div>
|
||||
<div class="mc-line sys">› controller online · 4 modules in orbit (Retriever, ReActPlanner, Greeter, ChatView)</div>
|
||||
<div class="mc-line sys">› ready. type a prompt below or pick a suggestion.</div>
|
||||
</div>
|
||||
<div class="mc-input">
|
||||
<span class="prompt">›</span>
|
||||
<input id="mcInput" type="text" placeholder="ask the agent…" autocomplete="off" />
|
||||
<button class="send" id="mcSend" type="button">Transmit</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mc-suggestions">
|
||||
<button class="mc-suggestion" data-q="Explain MMVC" type="button">Explain MMVC</button>
|
||||
<button class="mc-suggestion" data-q="Build a RAG agent" type="button">Build a RAG agent</button>
|
||||
<button class="mc-suggestion" data-q="Add a tool to my Nibiru module" type="button">Add a tool</button>
|
||||
<button class="mc-suggestion" data-q="Write a haiku about modules" type="button">Write a haiku</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
220
docs/src/components/MmvcStage.astro
Normal file
220
docs/src/components/MmvcStage.astro
Normal file
@@ -0,0 +1,220 @@
|
||||
---
|
||||
/**
|
||||
* MMVC pinned narrative — five-panel sticky-scroll sequence.
|
||||
*
|
||||
* Roles: Model · AI · Module · Controller · View. The "AI" panel was added
|
||||
* as a first-class step between Model and Module — Nibiru ships an AI module
|
||||
* (chat / embed / RAG / agent plugins) that wraps raw weights into intent.
|
||||
*
|
||||
* CSS is a faithful port of design-system/source/index-v2.html §MMVC PINNED
|
||||
* NARRATIVE (lines 408-518). Markup mirrors the mockup, with one extra panel
|
||||
* + step. Track height scaled from 400vh (4 panels) to 500vh (5 panels).
|
||||
*
|
||||
* The scroll-spy + camera presets live in public/js/nibiru-scene.js — that
|
||||
* file's step math (Math.floor(progress * N) and clamp) was bumped from 4→5
|
||||
* in the same change.
|
||||
*/
|
||||
---
|
||||
<section class="section mmvc-stage" id="mmvc" aria-labelledby="mmvc-h">
|
||||
<div class="container">
|
||||
<div class="section-head">
|
||||
<span class="eyebrow">The pattern · 01</span>
|
||||
<h2 id="mmvc-h" class="section-title">Five roles. <span class="accent">One orbit.</span></h2>
|
||||
<p class="section-sub">MMVC generalises classic MVC for AI workloads. The monolithic Model splits — first into weights, then into the AI patterns and typed capabilities that wrap them. Watch the system come into focus.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mmvc-track" id="mmvcTrack">
|
||||
<div class="mmvc-pin">
|
||||
<!-- Progress rail -->
|
||||
<div class="mmvc-progress" aria-hidden="true">
|
||||
<div class="step active" data-step="0"><span class="bar"></span><span>Model</span></div>
|
||||
<div class="step" data-step="1"><span class="bar"></span><span>AI</span></div>
|
||||
<div class="step" data-step="2"><span class="bar"></span><span>Module</span></div>
|
||||
<div class="step" data-step="3"><span class="bar"></span><span>Controller</span></div>
|
||||
<div class="step" data-step="4"><span class="bar"></span><span>View</span></div>
|
||||
</div>
|
||||
|
||||
<div class="mmvc-copy">
|
||||
<div class="panel active" data-panel="0">
|
||||
<div class="step-num">01 / 05 — MODEL</div>
|
||||
<h3>The <span class="pop">star</span><br/>at the centre.</h3>
|
||||
<p>The <strong>Model</strong> is the underlying ML model. Weights, runtime, inference — wherever it lives, on a laptop or a cluster. From the Controller's view it is stateless and addressable: <code>Model::load('nibiru-base-7B')</code>.</p>
|
||||
<p>One Model, many Modules.</p>
|
||||
</div>
|
||||
<div class="panel" data-panel="1">
|
||||
<div class="step-num">02 / 05 — AI</div>
|
||||
<h3>The <span class="pop">spark</span><br/>that lights it up.</h3>
|
||||
<p>The <strong>AI</strong> module turns weights into intent. Chat, embeddings, retrieval, agents — the inference patterns Nibiru ships with, wrapped around any Model the runtime can reach.</p>
|
||||
<p>From <code>Model::load()</code> to <code>Ai::ask()</code> in one hop.</p>
|
||||
</div>
|
||||
<div class="panel" data-panel="2">
|
||||
<div class="step-num">03 / 05 — MODULE</div>
|
||||
<h3>Modules <span class="pop">orbit</span><br/>the Model.</h3>
|
||||
<p>A <strong>Module</strong> is a typed capability — a retriever, a planner, a tool, a vision encoder. It has typed inputs and outputs. Composable. Pure-function-shaped over the Model.</p>
|
||||
<p>This is the layer MVC never had. Capabilities live here, not in the Model.</p>
|
||||
</div>
|
||||
<div class="panel" data-panel="3">
|
||||
<div class="step-num">04 / 05 — CONTROLLER</div>
|
||||
<h3>The <span class="pop">gravity</span><br/>between them.</h3>
|
||||
<p>The <strong>Controller</strong> orchestrates. It decides which Modules run, in what order, and how their outputs flow. Holds policy, never weights — the same Controller drives a chat agent or a batch job.</p>
|
||||
</div>
|
||||
<div class="panel" data-panel="4">
|
||||
<div class="step-num">05 / 05 — VIEW</div>
|
||||
<h3>The <span class="pop">surface</span><br/>at the horizon.</h3>
|
||||
<p>The <strong>View</strong> is the user-facing layer — <code>ChatView</code>, <code>VoiceView</code>, <code>APIView</code>. Swap one for another and the Controller does not change. Same agent, different sky.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mmvc-visual" id="mmvcVisual">
|
||||
<canvas id="mmvc-canvas" aria-hidden="true"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style is:global>
|
||||
/* ============== MMVC PINNED NARRATIVE — verbatim from index-v2.html
|
||||
§lines 408-518 (4-panel mockup), with .mmvc-track height scaled from
|
||||
400vh → 500vh for the 5th panel. ============== */
|
||||
.mmvc-stage {
|
||||
position: relative;
|
||||
padding: 0;
|
||||
}
|
||||
.mmvc-track {
|
||||
position: relative;
|
||||
height: 500vh; /* 5 panels worth of scroll (mockup was 400vh for 4) */
|
||||
}
|
||||
.mmvc-pin {
|
||||
position: sticky; top: 0;
|
||||
height: 100vh;
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
background: radial-gradient(ellipse at 50% 50%, var(--nibiru-plum, #1c0f3a), var(--nibiru-space, #0a0414) 60%);
|
||||
}
|
||||
.mmvc-visual {
|
||||
position: absolute; inset: 0;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
z-index: 0;
|
||||
opacity: 0.45;
|
||||
}
|
||||
#mmvc-canvas {
|
||||
position: absolute; inset: 0;
|
||||
width: 100%; height: 100%;
|
||||
}
|
||||
.mmvc-copy {
|
||||
position: absolute; inset: 0;
|
||||
z-index: 2;
|
||||
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||
padding: 0 24px;
|
||||
text-align: center;
|
||||
}
|
||||
.mmvc-copy .panel {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
transform: translate(-50%, calc(-50% + 24px));
|
||||
display: flex; flex-direction: column; align-items: center;
|
||||
width: min(720px, calc(100vw - 48px));
|
||||
text-align: center;
|
||||
opacity: 0;
|
||||
transition: opacity 500ms ease, transform 500ms ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
.mmvc-copy .panel.active {
|
||||
opacity: 1; transform: translate(-50%, -50%);
|
||||
pointer-events: auto;
|
||||
}
|
||||
.mmvc-copy .step-num {
|
||||
font-family: 'JetBrains Mono', ui-monospace, 'SF Mono', Menlo, monospace;
|
||||
font-size: 12px; letter-spacing: 0.18em;
|
||||
color: var(--nibiru-nebula-mag, #b86bff);
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 24px;
|
||||
display: inline-flex; align-items: center; gap: 12px;
|
||||
justify-content: center;
|
||||
}
|
||||
.mmvc-copy .step-num::before,
|
||||
.mmvc-copy .step-num::after {
|
||||
content: ''; width: 28px; height: 1px;
|
||||
background: var(--nibiru-nebula-mag, #b86bff);
|
||||
opacity: 0.6;
|
||||
}
|
||||
.mmvc-copy h3 {
|
||||
font-family: 'Space Grotesk', 'Inter Tight', ui-sans-serif, sans-serif;
|
||||
font-size: clamp(48px, 6vw, 88px);
|
||||
font-weight: 400;
|
||||
line-height: 0.98;
|
||||
letter-spacing: -0.035em;
|
||||
margin: 0 0 24px;
|
||||
color: var(--nibiru-star, #f4eedb);
|
||||
}
|
||||
.mmvc-copy h3 em { font-style: normal; font-weight: 500; }
|
||||
.mmvc-copy h3 .pop {
|
||||
background: linear-gradient(110deg, #ffb574, #b86bff);
|
||||
-webkit-background-clip: text; background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
font-style: normal; font-weight: 500;
|
||||
}
|
||||
.mmvc-copy p {
|
||||
font-size: 20px; line-height: 1.55;
|
||||
color: rgba(244, 238, 219, 0.85);
|
||||
max-width: 56ch;
|
||||
margin: 0 auto 16px;
|
||||
text-wrap: pretty;
|
||||
}
|
||||
.mmvc-copy code {
|
||||
background: rgba(244, 238, 219, 0.06);
|
||||
border: 1px solid var(--nibiru-line, rgba(244, 238, 219, 0.12));
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.85em;
|
||||
font-family: 'JetBrains Mono', ui-monospace, monospace;
|
||||
color: var(--nibiru-iris-soft, #d4b4ff);
|
||||
}
|
||||
.mmvc-progress {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 80px;
|
||||
transform: translateX(-50%);
|
||||
display: flex; gap: 8px; align-items: center;
|
||||
z-index: 4;
|
||||
flex-wrap: nowrap;
|
||||
justify-content: center;
|
||||
white-space: nowrap;
|
||||
padding: 0 16px;
|
||||
}
|
||||
.mmvc-progress .step {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
color: var(--nibiru-muted, #6e6680);
|
||||
font-family: 'JetBrains Mono', ui-monospace, monospace;
|
||||
font-size: 11px; letter-spacing: 0.10em; text-transform: uppercase;
|
||||
transition: color 240ms ease;
|
||||
padding-right: 10px;
|
||||
}
|
||||
.mmvc-progress .step .bar {
|
||||
width: 24px; height: 2px;
|
||||
background: var(--nibiru-line, rgba(244, 238, 219, 0.12));
|
||||
transition: background 240ms ease, width 240ms ease;
|
||||
border-radius: 2px;
|
||||
}
|
||||
.mmvc-progress .step.active { color: var(--nibiru-star, #f4eedb); }
|
||||
.mmvc-progress .step.active .bar {
|
||||
background: var(--nibiru-nebula-mag, #b86bff);
|
||||
width: 40px;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.mmvc-progress { gap: 4px; font-size: 9px; padding: 0 8px; }
|
||||
.mmvc-progress .step { gap: 4px; padding-right: 4px; }
|
||||
.mmvc-progress .step .bar { width: 14px; }
|
||||
.mmvc-progress .step.active .bar { width: 24px; }
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.mmvc-track { height: 460vh; }
|
||||
.mmvc-copy h3 { font-size: clamp(36px, 8vw, 56px); }
|
||||
.mmvc-copy p { font-size: 16px; }
|
||||
.mmvc-progress { top: 32px; }
|
||||
}
|
||||
</style>
|
||||
101
docs/src/components/PageTitle.astro
Normal file
101
docs/src/components/PageTitle.astro
Normal file
@@ -0,0 +1,101 @@
|
||||
---
|
||||
/**
|
||||
* PageTitle override — emits the docs-page mockup's article header:
|
||||
* .breadcrumbs · <h1> with <em> gradient accent · .doc-lede · .doc-meta
|
||||
*
|
||||
* Replaces Starlight's bare-<h1> default at
|
||||
* node_modules/@astrojs/starlight/components/PageTitle.astro
|
||||
*
|
||||
* Data comes from Astro.locals.starlightRoute. Styles live in
|
||||
* src/styles/starlight-docs-bridge.css §08 (.breadcrumbs / .doc-lede / .doc-meta)
|
||||
*/
|
||||
// Starlight's internal PAGE_TITLE_ID constant (not exported publicly).
|
||||
// Inlined here so the H1 anchor matches what Starlight's TOC scroll-spy expects.
|
||||
const PAGE_TITLE_ID = '_top';
|
||||
|
||||
const route = Astro.locals.starlightRoute;
|
||||
const { entry, sidebar, lastUpdated, editUrl, locale } = route;
|
||||
|
||||
const title = entry.data.title;
|
||||
const description = entry.data.description;
|
||||
|
||||
// Wrap last word of title in <em> for the amber→magenta gradient accent.
|
||||
// Pages can opt out by setting frontmatter `accent: false`.
|
||||
const accent = (entry.data as Record<string, unknown>).accent;
|
||||
let titleHead = title;
|
||||
let titleTail = '';
|
||||
if (accent !== false) {
|
||||
const idx = title.lastIndexOf(' ');
|
||||
if (idx > 0) {
|
||||
titleHead = title.slice(0, idx);
|
||||
titleTail = title.slice(idx + 1);
|
||||
}
|
||||
}
|
||||
|
||||
// Breadcrumbs — walk sidebar to find the current entry's ancestry.
|
||||
type SidebarItem = { label: string; href?: string; isCurrent?: boolean; entries?: SidebarItem[]; type?: string };
|
||||
function findCrumbs(items: SidebarItem[], trail: SidebarItem[] = []): SidebarItem[] | null {
|
||||
for (const item of items) {
|
||||
const next = [...trail, item];
|
||||
if (item.isCurrent) return next;
|
||||
if (item.entries) {
|
||||
const found = findCrumbs(item.entries, next);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
const crumbs = findCrumbs(sidebar as SidebarItem[]) ?? [];
|
||||
// "Docs" root crumb (localized via Starlight i18n)
|
||||
const docsRoot = locale ? `/${locale}/` : '/';
|
||||
|
||||
// Reading time — rough estimate from rendered body word count (250 wpm).
|
||||
const bodyText = (entry.body ?? '').replace(/[`*_#>\-]+/g, ' ').replace(/\s+/g, ' ').trim();
|
||||
const wordCount = bodyText ? bodyText.split(' ').length : 0;
|
||||
const minutes = Math.max(1, Math.round(wordCount / 250));
|
||||
|
||||
// Last-updated — relative format ("2 days ago")
|
||||
let updatedRel: string | null = null;
|
||||
if (lastUpdated) {
|
||||
const now = Date.now();
|
||||
const diffMs = now - lastUpdated.getTime();
|
||||
const day = 86400000;
|
||||
const rtf = new Intl.RelativeTimeFormat(locale ?? 'en', { numeric: 'auto' });
|
||||
if (diffMs < day) updatedRel = rtf.format(-Math.round(diffMs / 3600000), 'hour');
|
||||
else if (diffMs < 30 * day) updatedRel = rtf.format(-Math.round(diffMs / day), 'day');
|
||||
else if (diffMs < 365 * day) updatedRel = rtf.format(-Math.round(diffMs / (30 * day)), 'month');
|
||||
else updatedRel = rtf.format(-Math.round(diffMs / (365 * day)), 'year');
|
||||
}
|
||||
|
||||
// Stability badge — read frontmatter `status` (stable | beta | experimental); default 'Stable'
|
||||
const status = ((entry.data as Record<string, unknown>).status as string | undefined) ?? 'stable';
|
||||
const statusLabel = status.charAt(0).toUpperCase() + status.slice(1);
|
||||
---
|
||||
|
||||
{crumbs.length > 0 && (
|
||||
<nav class="breadcrumbs" aria-label="Breadcrumb">
|
||||
<a href={docsRoot}>Docs</a>
|
||||
<span class="sep">/</span>
|
||||
{crumbs.map((c, i) => (
|
||||
i === crumbs.length - 1
|
||||
? <span>{c.label}</span>
|
||||
: <>
|
||||
{c.href ? <a href={c.href}>{c.label}</a> : <span>{c.label}</span>}
|
||||
<span class="sep">/</span>
|
||||
</>
|
||||
))}
|
||||
</nav>
|
||||
)}
|
||||
|
||||
<h1 id={PAGE_TITLE_ID}>
|
||||
{titleHead}{titleTail && <> <em>{titleTail}</em></>}
|
||||
</h1>
|
||||
|
||||
{description && <p class="doc-lede">{description}</p>}
|
||||
|
||||
<div class="doc-meta">
|
||||
<span><span class="pulse"></span> <strong>{statusLabel}</strong></span>
|
||||
{updatedRel && <span>Updated <strong>{updatedRel}</strong></span>}
|
||||
{minutes > 0 && <span>Reading time <strong>~ {minutes} min</strong></span>}
|
||||
{editUrl && <span>Edit on <a href={editUrl.toString()}><strong>GitHub</strong></a></span>}
|
||||
</div>
|
||||
97
docs/src/components/SpacecraftGrid.astro
Normal file
97
docs/src/components/SpacecraftGrid.astro
Normal file
@@ -0,0 +1,97 @@
|
||||
---
|
||||
/**
|
||||
* Spacecraft — three "vessels" (Lite / Base / Pro). Pure CSS, no JS.
|
||||
* Maps to Nibiru's actual offering:
|
||||
* Lite — clone & start (the framework alone)
|
||||
* Base — recommended, with the AI module + Oracle
|
||||
* Pro — full deployment with CMS, ACL, Elasticsearch and Machineryscout
|
||||
*/
|
||||
---
|
||||
<section class="section" id="download" aria-labelledby="dl-h">
|
||||
<div class="container">
|
||||
<div class="section-head">
|
||||
<span class="eyebrow">Choose your craft · 04</span>
|
||||
<h2 id="dl-h" class="section-title">Three vessels. <span class="accent">Same orbit.</span></h2>
|
||||
<p class="section-sub">Open source, MIT-licensed, no SDK. Pick the configuration that matches your altitude.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="craft-grid">
|
||||
<article class="craft lite">
|
||||
<span class="badge">Probe · Edge</span>
|
||||
<h3 class="name">Nibiru <em>Lite</em></h3>
|
||||
<p class="role">Just the framework. Five commands and you're running.</p>
|
||||
<div class="silhouette" aria-hidden="true">
|
||||
<svg viewBox="0 0 100 100" fill="none" stroke="currentColor" stroke-width="1.2" style="color: rgba(122, 214, 163, 0.9);">
|
||||
<circle cx="50" cy="50" r="8" fill="currentColor"></circle>
|
||||
<ellipse cx="50" cy="50" rx="32" ry="6"></ellipse>
|
||||
<ellipse cx="50" cy="50" rx="32" ry="6" transform="rotate(60 50 50)"></ellipse>
|
||||
<ellipse cx="50" cy="50" rx="32" ry="6" transform="rotate(-60 50 50)"></ellipse>
|
||||
<circle cx="82" cy="50" r="2.5" fill="currentColor"></circle>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="stats">
|
||||
<div><span class="l">Disk</span><span class="v">~ 5 MB</span></div>
|
||||
<div><span class="l">Boot</span><span class="v">3 min</span></div>
|
||||
<div><span class="l">DB drivers</span><span class="v">5</span></div>
|
||||
<div><span class="l">License</span><span class="v">MIT</span></div>
|
||||
</div>
|
||||
<a class="craft-cta" href="/en/start/quick-start/">Read the quick start <span class="arrow">→</span></a>
|
||||
</article>
|
||||
|
||||
<article class="craft base">
|
||||
<span class="badge">Shuttle · Daily driver</span>
|
||||
<h3 class="name">Nibiru <em>Base</em></h3>
|
||||
<p class="role">Framework + AI module + Oracle, wired to your Ollama.</p>
|
||||
<div class="silhouette" aria-hidden="true">
|
||||
<svg viewBox="0 0 100 100" fill="none" stroke="currentColor" stroke-width="1.2" style="color: rgba(184, 107, 255, 0.9);">
|
||||
<circle cx="50" cy="50" r="14" fill="currentColor" opacity="0.4"></circle>
|
||||
<circle cx="50" cy="50" r="9" fill="currentColor"></circle>
|
||||
<ellipse cx="50" cy="50" rx="38" ry="9"></ellipse>
|
||||
<ellipse cx="50" cy="50" rx="38" ry="9" transform="rotate(45 50 50)"></ellipse>
|
||||
<ellipse cx="50" cy="50" rx="38" ry="9" transform="rotate(90 50 50)"></ellipse>
|
||||
<ellipse cx="50" cy="50" rx="38" ry="9" transform="rotate(-45 50 50)"></ellipse>
|
||||
<circle cx="88" cy="50" r="3" fill="currentColor"></circle>
|
||||
<circle cx="22" cy="22" r="2" fill="currentColor"></circle>
|
||||
<circle cx="78" cy="78" r="2.5" fill="currentColor"></circle>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="stats">
|
||||
<div><span class="l">Modules</span><span class="v">+ ai · oracle</span></div>
|
||||
<div><span class="l">Tools</span><span class="v">3 built-in</span></div>
|
||||
<div><span class="l">Backends</span><span class="v">Ollama · OpenAI · Claude</span></div>
|
||||
<div><span class="l">License</span><span class="v">MIT</span></div>
|
||||
</div>
|
||||
<a class="craft-cta" href="/en/ai/module/overview/">Read the AI docs <span class="arrow">→</span></a>
|
||||
</article>
|
||||
|
||||
<article class="craft pro">
|
||||
<span class="badge">Mothership · Heavy lift</span>
|
||||
<h3 class="name">Nibiru <em>Pro</em></h3>
|
||||
<p class="role">Production stack — CMS, ACL, Elasticsearch, blue-green deploys.</p>
|
||||
<div class="silhouette" aria-hidden="true">
|
||||
<svg viewBox="0 0 100 100" fill="none" stroke="currentColor" stroke-width="1.2" style="color: rgba(255, 181, 116, 0.9);">
|
||||
<circle cx="50" cy="50" r="20" fill="currentColor" opacity="0.25"></circle>
|
||||
<circle cx="50" cy="50" r="12" fill="currentColor"></circle>
|
||||
<ellipse cx="50" cy="50" rx="44" ry="12"></ellipse>
|
||||
<ellipse cx="50" cy="50" rx="44" ry="12" transform="rotate(30 50 50)"></ellipse>
|
||||
<ellipse cx="50" cy="50" rx="44" ry="12" transform="rotate(60 50 50)"></ellipse>
|
||||
<ellipse cx="50" cy="50" rx="44" ry="12" transform="rotate(90 50 50)"></ellipse>
|
||||
<ellipse cx="50" cy="50" rx="44" ry="12" transform="rotate(120 50 50)"></ellipse>
|
||||
<ellipse cx="50" cy="50" rx="44" ry="12" transform="rotate(150 50 50)"></ellipse>
|
||||
<circle cx="92" cy="50" r="3.5" fill="currentColor"></circle>
|
||||
<circle cx="14" cy="36" r="2.5" fill="currentColor"></circle>
|
||||
<circle cx="68" cy="86" r="3" fill="currentColor"></circle>
|
||||
<circle cx="32" cy="14" r="2" fill="currentColor"></circle>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="stats">
|
||||
<div><span class="l">Modules</span><span class="v">+ cms · acl · search</span></div>
|
||||
<div><span class="l">Migrations</span><span class="v">161 in prod</span></div>
|
||||
<div><span class="l">Search</span><span class="v">Elasticsearch</span></div>
|
||||
<div><span class="l">Deploy</span><span class="v">blue-green</span></div>
|
||||
</div>
|
||||
<a class="craft-cta" href="/en/showcase/projects/">See it in production <span class="arrow">→</span></a>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
89
docs/src/components/Swatch.astro
Normal file
89
docs/src/components/Swatch.astro
Normal file
@@ -0,0 +1,89 @@
|
||||
---
|
||||
interface Props {
|
||||
token: string;
|
||||
hex: string;
|
||||
name: string;
|
||||
usage?: string;
|
||||
}
|
||||
const { token, hex, name, usage } = Astro.props as Props;
|
||||
---
|
||||
|
||||
<div class="swatch">
|
||||
<div class="swatch__chip" style={`background:${hex}`} aria-hidden="true"></div>
|
||||
<div class="swatch__meta">
|
||||
<strong class="swatch__name">{name}</strong>
|
||||
<code class="swatch__token">{token}</code>
|
||||
<code class="swatch__hex">{hex}</code>
|
||||
{usage && <p class="swatch__usage">{usage}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.swatch {
|
||||
display: grid;
|
||||
grid-template-columns: 96px 1fr;
|
||||
gap: 1.2rem;
|
||||
align-items: center;
|
||||
padding: 0.75rem 0;
|
||||
margin: 0;
|
||||
border-bottom: 1px solid rgba(31, 27, 46, 0.08);
|
||||
}
|
||||
.swatch:last-child { border-bottom: 0; }
|
||||
|
||||
.swatch__chip {
|
||||
width: 96px;
|
||||
height: 64px;
|
||||
border-radius: 3px;
|
||||
box-shadow:
|
||||
inset 0 0 0 1px rgba(0, 0, 0, 0.04),
|
||||
0 2px 8px -3px rgba(31, 27, 46, 0.20);
|
||||
}
|
||||
|
||||
.swatch__meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.1rem;
|
||||
}
|
||||
|
||||
.swatch__name {
|
||||
font-family: 'Bricolage Grotesque', system-ui, sans-serif;
|
||||
font-variation-settings: 'opsz' 24, 'wght' 600;
|
||||
letter-spacing: -0.018em;
|
||||
color: var(--ink, #1f1b2e);
|
||||
font-size: 1.05rem;
|
||||
margin-bottom: 0.05rem;
|
||||
}
|
||||
|
||||
.swatch__token {
|
||||
font-family: 'JetBrains Mono', ui-monospace, monospace;
|
||||
font-size: 0.74rem;
|
||||
color: var(--iris-deep, #5e548c);
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.swatch__hex {
|
||||
font-family: 'JetBrains Mono', ui-monospace, monospace;
|
||||
font-size: 0.74rem;
|
||||
color: var(--ink-faint, #847b94);
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.swatch__usage {
|
||||
margin: 0.4rem 0 0;
|
||||
font-size: 0.85rem;
|
||||
color: var(--ink-soft, #4a4258);
|
||||
line-height: 1.55;
|
||||
max-width: 38rem;
|
||||
font-variation-settings: 'opsz' 14, 'wght' 400;
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] .swatch { border-bottom-color: rgba(236, 230, 243, 0.10); }
|
||||
:root[data-theme='dark'] .swatch__name { color: #ece6f3; }
|
||||
:root[data-theme='dark'] .swatch__token { color: #c2dcec; }
|
||||
:root[data-theme='dark'] .swatch__hex { color: #9c92ad; }
|
||||
:root[data-theme='dark'] .swatch__usage { color: #c5bfd1; }
|
||||
</style>
|
||||
21
docs/src/components/ThemeSelectStub.astro
Normal file
21
docs/src/components/ThemeSelectStub.astro
Normal file
@@ -0,0 +1,21 @@
|
||||
---
|
||||
/*
|
||||
* Theme picker stub — Nibiru is dark-only.
|
||||
*
|
||||
* The Cosmos design system was designed dark-first; the light theme
|
||||
* was kept as a tinker option, but per the brief we ship a single,
|
||||
* consistent night-sky experience. This component replaces Starlight's
|
||||
* default ThemeSelect with nothing so the picker disappears from the
|
||||
* header, and an inline script in <head> forces data-theme="dark" so
|
||||
* any prior `localStorage` light setting is overridden.
|
||||
*/
|
||||
---
|
||||
|
||||
<script is:inline>
|
||||
// Force-dark for the entire site. Stomps any cached preference and
|
||||
// any Starlight theme-script that might have run before.
|
||||
(function () {
|
||||
try { localStorage.setItem('starlight-theme', 'dark'); } catch (e) {}
|
||||
document.documentElement.setAttribute('data-theme', 'dark');
|
||||
})();
|
||||
</script>
|
||||
13
docs/src/components/ToTop.astro
Normal file
13
docs/src/components/ToTop.astro
Normal file
@@ -0,0 +1,13 @@
|
||||
---
|
||||
/**
|
||||
* ToTop — fixed scroll-to-top button. Becomes visible after 600 px of scroll.
|
||||
* Lives in its own component because inline `<script>` blocks inside .mdx
|
||||
* files are parsed by MDX, which trips on object-literal braces.
|
||||
*/
|
||||
---
|
||||
<button class="to-top" id="toTop" aria-label="Back to top" type="button">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M12 19V5M5 12l7-7 7 7"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
7
docs/src/content.config.ts
Normal file
7
docs/src/content.config.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { defineCollection } from 'astro:content';
|
||||
import { docsLoader } from '@astrojs/starlight/loaders';
|
||||
import { docsSchema } from '@astrojs/starlight/schema';
|
||||
|
||||
export const collections = {
|
||||
docs: defineCollection({ loader: docsLoader(), schema: docsSchema() }),
|
||||
};
|
||||
124
docs/src/content/docs/de/ai/corpus.md
Normal file
124
docs/src/content/docs/de/ai/corpus.md
Normal file
@@ -0,0 +1,124 @@
|
||||
---
|
||||
title: "Trainingskorpus"
|
||||
description: "Wie die Dokumente als LoRA-fähige Trainingsmenge exportiert werden und wie sie neu generiert werden können."
|
||||
---
|
||||
|
||||
Jede Seite in dieser Dokumentation ist auch ein **Trainingsdatenpunkt**. Nibiru bietet ein Skript, das einen sauberen JSONL-Korpus extrahiert, der für die LoRA-Fine-Tuning eines offenen Modells – wie Llama, Mistral, Qwen oder Gemma – auf spezifische Kenntnisse von Nibiru geeignet ist.
|
||||
|
||||
## Führen Sie es aus
|
||||
```bash
|
||||
cd docs
|
||||
npm run build:corpus
|
||||
```
|
||||
Dies schreibt:
|
||||
```
|
||||
docs/dist/corpus/
|
||||
├── instructions.jsonl # instruction → response pairs
|
||||
├── chat.jsonl # OpenAI/Anthropic chat-message format
|
||||
├── completion.jsonl # plain prompt → completion (legacy)
|
||||
└── chunks.jsonl # raw Markdown chunks (one per H2/H3 section)
|
||||
```
|
||||
## Formate
|
||||
|
||||
### `anweisungen.jsonl`
|
||||
|
||||
LoRA-freundliche Anweisungsoptimierung:
|
||||
```json
|
||||
{
|
||||
"instruction": "How do I scaffold a new module in Nibiru?",
|
||||
"input": "",
|
||||
"output": "Run `./nibiru -m <name>`, optionally with `-g` for Graylog hooks. This creates `application/module/<name>/` with traits/, plugins/, interfaces/, settings/<name>.ini and the main `<name>.php` class implementing `IModule`."
|
||||
}
|
||||
```
|
||||
Jede Eintragung wird aus einem Dokumentationsabschnitt generiert, der eine klare Frage (abgeleitet vom H2/H3-Titel) und den Abschnittstext als Antwort enthält.
|
||||
|
||||
### `chat.jsonl`
|
||||
|
||||
OpenAI Chat / Anthropic Nachrichtenformat:
|
||||
```json
|
||||
{
|
||||
"messages": [
|
||||
{"role": "system", "content": "You are an expert on the Nibiru PHP framework."},
|
||||
{"role": "user", "content": "How do I scaffold a new module?"},
|
||||
{"role": "assistant", "content": "Run `./nibiru -m <name>`. …"}
|
||||
]
|
||||
}
|
||||
```
|
||||
Kompatibel mit OpenAI Fine-Tunes, der API von Anthropic zur Bewertung und den meisten LoRA-Tools, die Eingaben im Chat-Format erwarten (Vorlage `sharegpt` von Axolotl, Unsloth, LLaMA-Factory).
|
||||
|
||||
### `chunks.jsonl`
|
||||
|
||||
Rohdaten für die Verwendung als RAG-Retrieval-Daten:
|
||||
```json
|
||||
{
|
||||
"id": "core/modules#observer-pattern",
|
||||
"title": "The observer pattern",
|
||||
"url": "/core/modules/#the-observer-pattern",
|
||||
"section": "core/modules",
|
||||
"language": "en",
|
||||
"tokens": 412,
|
||||
"content": "Modules implementing `SplSubject` can broadcast events…"
|
||||
}
|
||||
```
|
||||
Dies ist genau die Datei, die intern von [Oracle](/ai/oracle/) verwendet wird.
|
||||
|
||||
## Wie Chunks abgeleitet werden
|
||||
|
||||
Der Korpus-Bauer durchläuft jede `.md` / `.mdx`-Datei unter `src/content/docs/`, analysiert sie in einen AST und teilt sie an den Grenzen von H2/H3 auf. Er erfordert:
|
||||
|
||||
- Ein Abschnitt pro H2-Kapitel (oder H3, wenn das H2 leer ist).
|
||||
- ~200–800 Token pro Abschnitt (teilen Sie es auf, wenn es länger ist, zusammenführen Sie es, wenn es kürzer ist).
|
||||
- Codeblöcke bleiben unverändert — teilen Sie sie niemals in der Mitte.
|
||||
- Jeder Abschnitt trägt seinen Quellpfad, seine Anker-URL und den Sprachcode bei.
|
||||
|
||||
Das Skript befindet sich in `scripts/build-corpus.mjs` und ist vollständig konfigurierbar.
|
||||
|
||||
## Vorgeschlagene LoRA-Rezepte
|
||||
|
||||
Ein pragmatischer Ausgangspunkt für ein 8-Billionen-Parameter-Grundmodell auf einer einzelnen A100 / 4090:
|
||||
```yaml
|
||||
# axolotl.yaml
|
||||
base_model: meta-llama/Llama-3.1-8B-Instruct
|
||||
adapter: lora
|
||||
lora_r: 16
|
||||
lora_alpha: 32
|
||||
lora_dropout: 0.05
|
||||
lora_target_modules: [q_proj, k_proj, v_proj, o_proj]
|
||||
|
||||
datasets:
|
||||
- path: docs/dist/corpus/chat.jsonl
|
||||
type: sharegpt
|
||||
|
||||
sequence_len: 4096
|
||||
sample_packing: true
|
||||
gradient_accumulation_steps: 4
|
||||
micro_batch_size: 2
|
||||
num_epochs: 3
|
||||
optimizer: adamw_bnb_8bit
|
||||
learning_rate: 0.0002
|
||||
warmup_ratio: 0.05
|
||||
bf16: true
|
||||
```
|
||||
Trainen Sie dann die LoRA-Gewichte zusammen und stellen Sie sie über Ollama, vLLM oder text-generation-inference bereit. Ändern Sie den `MODEL`-Eintrag des Orakels auf Ihren lokalen Endpunkt, und Sie haben eine vollständig Nibiru-native Chat-Benutzererfahrung.
|
||||
|
||||
## Erneut ausführen bei jeder Dokumentenänderung
|
||||
|
||||
Verbinden Sie es in Ihre CI ein:
|
||||
```yaml
|
||||
- name: Build corpus
|
||||
run: cd docs && npm run build:corpus
|
||||
- name: Upload corpus artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: nibiru-corpus
|
||||
path: docs/dist/corpus/
|
||||
```
|
||||
Wenn die Dokumentationen sich ändern, wird das Korpus neu erstellt; Consumer (Trainingspipelines, RAG-Indizes) haben immer die neuesten Daten.
|
||||
|
||||
## Sprachen
|
||||
|
||||
Der Korpus beachtet die Lokalisation. Seiten unter `en/` sind mit `language: en` markiert, deutsche Seiten mit `language: de` und so weiter. Trainieren Sie monolinguale oder mehrsprachige LoRAs, indem Sie das JSONL nach dem Feld `language` filtern.
|
||||
|
||||
## Lizenz
|
||||
|
||||
Die Dokumentation ist unter der gleichen BSD-4-Klausel lizenziert wie das Framework selbst. Der exportierte Korpus erbt diese Lizenz – Sie sind freigestellt, Modelle auf ihm für den kommerziellen Gebrauch zu feinabzustimmen, wobei eine Zitierung erforderlich ist.
|
||||
138
docs/src/content/docs/de/ai/module/agent.md
Normal file
138
docs/src/content/docs/de/ai/module/agent.md
Normal file
@@ -0,0 +1,138 @@
|
||||
---
|
||||
title: "Agent-Plugin"
|
||||
description: "Ein ReAct-stiliges Werkzeugnutzungs-Agent. Erweitern Sie das Werkzeug, um ihm jede PHP-Fähigkeit zu geben, die Sie schreiben können."
|
||||
---
|
||||
|
||||
Das Agent-Plugin ermöglicht es Ihnen, einer LLM **die Fähigkeit zu geben** — SQL-Abfragen auszuführen, HTTP-Endpunkte anzusprechen, Dateien zu lesen oder alles andere zu tun, was Sie als PHP-Methode ausdrücken können. Es führt eine ReAct-artige Schleife durch: Denken → Werkzeug-Aufruf → Beobachten → Wiederholen → Antworten.
|
||||
|
||||
## Fünf Zeilen, ein Agent
|
||||
```php
|
||||
use Nibiru\Module\Ai\Ai;
|
||||
use Nibiru\Module\Ai\Plugin\Tools\PdoQuery;
|
||||
|
||||
$ai = new Ai();
|
||||
echo $ai->agent()
|
||||
->withTools([new PdoQuery()])
|
||||
->run('How many active users do we have?');
|
||||
// → "We have 1,247 active users." (after the agent ran SELECT count(*)…)
|
||||
```
|
||||
## Wie es funktioniert
|
||||
```
|
||||
user task
|
||||
↓
|
||||
LLM gets system prompt with tool definitions
|
||||
↓
|
||||
LLM emits ```tool {"tool":"pdo_query","args":{"sql":"SELECT…"}}```
|
||||
↓
|
||||
Agent runs the tool, captures result
|
||||
↓
|
||||
LLM gets observation, decides: more tools or final answer?
|
||||
↓
|
||||
"FINAL: 1,247 active users."
|
||||
```
|
||||
Das Protokoll verwendet einen **gepufferten JSON-Sentinel** — `\`\`\`tool {...}\`\`\`` — den jeder Modell erzeugen kann. Es ist keine native Tool-Aufruf-API erforderlich, daher funktioniert es auf jedem Ollama-Modell out of the box. (Modelle, die eine native Tool-Aufruf-API unterstützen, können über einen Subclass eingesetzt werden, der `parseToolCall()` überschreibt.)
|
||||
|
||||
## Eingebaute Tools
|
||||
|
||||
Nibiru bietet drei Produkte an:
|
||||
|
||||
| Werkzeug | Was es macht |
|
||||
|---|---|
|
||||
| `Tools\PdoQuery` | Einzelner schreibgeschützter `SELECT`-Befehl gegen die App-Datenbank. Blockiert INSERT/UPDATE/DELETE/DROP/TRUNCATE/ALTER. Gibt bis zu 50 Zeilen als JSON zurück. |
|
||||
| `Tools\HttpGet` | Holt eine HTTP/HTTPS-URL mit optionalen Headern. Gibt den Body zurück, abgeschnitten auf 8 KB. |
|
||||
| `Tools\FileRead` | Liest eine Projektdatei über einen relativen Pfad. Blockiert die `..`-Reise. Gibt bis zu 8 KB zurück. |
|
||||
```php
|
||||
use Nibiru\Module\Ai\Plugin\Tools;
|
||||
|
||||
$agent = $ai->agent()->withTools([
|
||||
new Tools\PdoQuery(),
|
||||
new Tools\HttpGet(),
|
||||
new Tools\FileRead(),
|
||||
]);
|
||||
|
||||
// Multi-step task
|
||||
echo $agent->run(
|
||||
'Read application/controller/loginController.php and tell me '
|
||||
. 'whether it implements rate limiting.'
|
||||
);
|
||||
```
|
||||
Der Agent wird `file_read` mit dem Pfad aufrufen, die Quelle beobachten und basierend auf dem tatsächlichen Gesehenen antworten – nicht auf das, was es sich vorstellt.
|
||||
|
||||
## Ein benutzerdefiniertes Tool schreiben
|
||||
|
||||
Erweitern Sie `Tool`:
|
||||
```php
|
||||
namespace App\AiTools;
|
||||
|
||||
use Nibiru\Module\Ai\Plugin\Tool;
|
||||
|
||||
class StripeRefund extends Tool
|
||||
{
|
||||
public function name(): string { return 'stripe_refund'; }
|
||||
|
||||
public function description(): string {
|
||||
return 'Issue a Stripe refund for a charge ID.';
|
||||
}
|
||||
|
||||
public function schema(): array {
|
||||
return [
|
||||
'charge_id' => [
|
||||
'type' => 'string',
|
||||
'description' => 'A Stripe charge ID, e.g. ch_3K…',
|
||||
'required' => true,
|
||||
],
|
||||
'amount_cents' => [
|
||||
'type' => 'integer',
|
||||
'description' => 'Amount to refund in cents. Omit for full refund.',
|
||||
'required' => false,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
public function execute(array $args): mixed {
|
||||
$stripe = new \Stripe\StripeClient(getenv('STRIPE_SECRET_KEY'));
|
||||
$refund = $stripe->refunds->create(array_filter([
|
||||
'charge' => $args['charge_id'],
|
||||
'amount' => $args['amount_cents'] ?? null,
|
||||
]));
|
||||
return json_encode([
|
||||
'refund_id' => $refund->id,
|
||||
'status' => $refund->status,
|
||||
'amount' => $refund->amount,
|
||||
]);
|
||||
}
|
||||
}
|
||||
```
|
||||
Dann stecken Sie es ein:
|
||||
```php
|
||||
$ai->agent()
|
||||
->withTools([new \App\AiTools\StripeRefund(), new Tools\PdoQuery()])
|
||||
->run('Refund order #4421 — they were charged twice.');
|
||||
```
|
||||
Der Agent wird `pdo_query` verwenden, um die Gebühr zu finden, und dann `stripe_refund` mit dieser Gebührs-ID aufrufen.
|
||||
|
||||
## Den Trace betrachten
|
||||
```php
|
||||
$agent = $ai->agent()->withTools([new Tools\PdoQuery()]);
|
||||
$answer = $agent->run('How many products in the gold-plating category?');
|
||||
|
||||
foreach ($agent->trace() as $step) {
|
||||
echo "Step {$step['step']}: action={$step['action']}\n obs={$step['observation']}\n";
|
||||
}
|
||||
```
|
||||
Nützlich für das Debuggen, die Nachverfolgung von Abläufen oder zum Erstellen einer "Zeige dein Arbeitsvorgehen"-Benutzeroberfläche.
|
||||
|
||||
## Sicherheit
|
||||
|
||||
- **`PdoQuery` blocks writes.** Wenn Sie Schreibzugriff benötigen, erstellen Sie eine subklasse mit erhöhten Rechten und einer Überwachungsnachverfolgung. Heben Sie die SELECT-nur-Einschränkung im eingebauten Tool nicht auf.
|
||||
- **`HttpGet` allows any URL by default.** Sperren Sie es über eine Zulassungsliste in `[AI] http_allowed_hosts[]` (geplant) oder erstellen Sie eine `RestrictedHttpGet`-Subklasse, die URLs filtert.
|
||||
- **`FileRead` blocks `..`.** Es ist auf den Anwendungsstamm beschränkt.
|
||||
- **Max iterations.** `agent.max_iterations = 6` in der INI verhindert unkontrollierte Schleifen. Erhöhen Sie vorsichtig.
|
||||
- **Tool timeout.** `agent.tool_timeout = 30` (Sekunden). Ein Tool, das hängt, wird die Anfrage nicht ewig blockieren.
|
||||
|
||||
## Häufige Fallen
|
||||
|
||||
- **Vergessen Sie `withTools()`.** Ohne Werkzeuge ist der Agent nur ein regulärer `Chat`.
|
||||
- **Lassen Sie dem Agenten Geheimnisse sehen.** Legen Sie niemals API-Schlüssel, rohe Passwörter oder PII in die Antwort eines Tools — das Modell erhält den vollständigen String.
|
||||
- **Lange Werkzeugausgaben.** Jede Beobachtung wird an die Konversation angehängt. Ein Tool, das 50 KB ausgibt, erschöpft schnell den Kontext. Die eingebaute Tools haben eine Obergrenze von 8 KB; tun Sie das gleiche in Ihren benutzerdefinierten Tools.
|
||||
- **Kein Werkzeugaufruf in der Antwort = endgültige Antwort.** Wenn das Modell eine endgültige Antwort produziert, die *aussehen* wie einen Werkzeugaufruf, aber nicht validiert wird, behandelt der Agent es als endgültig. Seien Sie explizit im Prompt: "Geben Sie einen Werkzeugaufruf ODER eine endgültige Antwort aus, niemals beides."
|
||||
104
docs/src/content/docs/de/ai/module/chat.md
Normal file
104
docs/src/content/docs/de/ai/module/chat.md
Normal file
@@ -0,0 +1,104 @@
|
||||
---
|
||||
title: "Chat-Plugin"
|
||||
description: "Einzel- oder mehrstufige Chat-Vervollständigungen gegen einen beliebigen Ollama-kompatiblen Endpunkt."
|
||||
---
|
||||
|
||||
Das Chat-Plugin ist das einfachste Element des KI-Moduls. Es umhüllt Ollamas `/api/chat` mit einem flüssigen Builder, einer Konversationsmemory, einem automatischen Fallback auf ein Sicherheitsmodell und einem `ask()`-Shortcut für einen Einzeiler.
|
||||
|
||||
## API im Überblick
|
||||
```php
|
||||
$ai = new \Nibiru\Module\Ai\Ai();
|
||||
$chat = $ai->chat();
|
||||
|
||||
$chat->system('Be terse.'); // optional system prompt
|
||||
$chat->model('qwen2.5-coder:14b'); // override the configured model
|
||||
$chat->temperature(0.2); // override config
|
||||
$chat->maxTokens(512); // override config
|
||||
|
||||
$chat->user('Hello'); // append a user message
|
||||
$chat->assistant('Hi.'); // append an assistant message (rare)
|
||||
|
||||
$reply = $chat->complete(); // run the call, return text
|
||||
$reply = $chat->ask('How are you?'); // = ->user(...)->complete()
|
||||
|
||||
$chat->reset(); // clear messages, keep model + system
|
||||
$chat->history(); // [{role, content}, …]
|
||||
```
|
||||
## Einmalig
|
||||
```php
|
||||
echo (new \Nibiru\Module\Ai\Ai())
|
||||
->chat()
|
||||
->ask('In one sentence, what does Form::create() do?');
|
||||
```
|
||||
## Mehrfachgespräch
|
||||
```php
|
||||
$chat = $ai->chat();
|
||||
|
||||
$chat->user('Name three Nibiru singletons.');
|
||||
$singletons = $chat->complete(); // appended to history
|
||||
|
||||
$chat->user('What does the second one do?');
|
||||
$detail = $chat->complete(); // model has full context
|
||||
```
|
||||
## Überschreiben des Modells und des Stils pro Aufruf
|
||||
```php
|
||||
$german = $ai->chat()
|
||||
->system('Answer in German. Be precise.')
|
||||
->model('qwen2.5-coder:14b')
|
||||
->temperature(0.1)
|
||||
->ask('Wie definiere ich einen Controller?');
|
||||
```
|
||||
## Automatischer Fallback
|
||||
|
||||
Wenn `chat.model` (z.B. `nibiru-coder:1.0`) nicht auf dem Ollama-Server verfügbar ist, führt das Plugin den Aufruf mit `chat.fallback_model` (z.B. `qwen2.5-coder:14b`) erneut aus. Dies gewährleistet, dass Ihre Anwendung weiterhin funktioniert, während Sie die Feinabstimmung erstellen.
|
||||
```ini
|
||||
[AI]
|
||||
chat.model = "nibiru-coder:1.0"
|
||||
chat.fallback_model = "qwen2.5-coder:14b"
|
||||
```
|
||||
## Anbieter wechseln
|
||||
|
||||
Standard: Ollama. Um Anthropic Claude als Backend zu verwenden:
|
||||
```ini
|
||||
[AI]
|
||||
chat.provider = "anthropic"
|
||||
anthropic.api_key = "sk-ant-..."
|
||||
anthropic.model = "claude-haiku-4-5-20251001"
|
||||
```
|
||||
Der Chat-Plugin verfügt derzeit nicht über die Anthropic-Transport-Funktion im Framework-Modul — für jetzt ist das Muster in `scripts/lib/providers.mjs` der Dokumentationssite das Referenzmodell. (Siehe die [Roadmap](/en/ai/roadmap/).)
|
||||
|
||||
## Ein praktisches Muster: Chat als eine Aktion
|
||||
```php
|
||||
namespace Nibiru;
|
||||
use Nibiru\Adapter\Controller;
|
||||
use Nibiru\Module\Ai\Ai;
|
||||
|
||||
class supportController extends Controller
|
||||
{
|
||||
public function askAction(): void {
|
||||
View::forwardToJsonHeader();
|
||||
$q = trim($this->getPost('question', ''));
|
||||
if ($q === '') {
|
||||
View::assign(['data' => ['error' => 'question required']]);
|
||||
return;
|
||||
}
|
||||
$reply = (new Ai())->chat()
|
||||
->system('You are the Nibiru support assistant. Be brief.')
|
||||
->ask($q);
|
||||
View::assign(['data' => ['answer' => $reply]]);
|
||||
}
|
||||
|
||||
public function pageAction(): void {}
|
||||
public function navigationAction(): void {
|
||||
JsonNavigation::getInstance()->loadJsonNavigationArray();
|
||||
}
|
||||
}
|
||||
```
|
||||
Sechs Zeilen plus Boilerplate und Sie haben einen durch Ihren eigenen Ollama gestützten AJAX-aufrufbaren KI-Endpunkt.
|
||||
|
||||
## Häufige Fallen
|
||||
|
||||
- **Vergessen Sie `complete()`.** Der fließende Builder führt nichts aus, bis `complete()` oder `ask()` aufgerufen wird.
|
||||
- **Aufruf von `assistant()` zwischen Benutzerabläufen.** Es dient dazu, eine gespeicherte Konversation wiederzugeben und nicht für die normale Nutzung.
|
||||
- **Lange Konversationen.** Jeder Ablauf sendet die vollständige Geschichte erneut. Verkürzen Sie dies mit `reset()` oder durch Schneiden von `history()`, wenn Sie den älteren Kontext nicht mehr benötigen.
|
||||
- **Temperatur zu niedrig einstellen.** 0 macht das Modell steif; 0,3–0,5 ist der ideale Bereich für technische Antworten.
|
||||
87
docs/src/content/docs/de/ai/module/embed.md
Normal file
87
docs/src/content/docs/de/ai/module/embed.md
Normal file
@@ -0,0 +1,87 @@
|
||||
---
|
||||
title: "Einbetten des Plugins"
|
||||
description: "Text in Vektoren umwandeln. Kosinusähnlichkeit. Kompakte Speicherung. Die Infrastruktur unter dem RAG-Plugin, die auch selbst nützlich ist."
|
||||
---
|
||||
|
||||
Das Embed-Plugin ist ein dünner Wrapper um Ollama's `/api/embeddings` plus drei nützliche Hilfsmittel: Kosinusähnlichkeit, kompakte Base64-Packung und inverse Entpackung. Das [RAG-Plugin](/en/ai/module/rag/) verwendet es intern, ist aber auch selbstständig hilfreich – für Clustering, Deduplikation, semantische Suche und Anomalieerkennung.
|
||||
|
||||
## API
|
||||
```php
|
||||
$embed = (new \Nibiru\Module\Ai\Ai())->embed();
|
||||
|
||||
$vec = $embed->one('controller'); // float[]
|
||||
$vectors = $embed->batch(['a', 'b', 'c']); // float[][]
|
||||
|
||||
$score = \Nibiru\Module\Ai\Plugin\Embed::cosine($a, $b); // 0..1
|
||||
$packed = \Nibiru\Module\Ai\Plugin\Embed::pack($vec); // base64 string
|
||||
$vec = \Nibiru\Module\Ai\Plugin\Embed::unpack($packed); // back to float[]
|
||||
```
|
||||
## Muster: Entfernen ähnlicher Zeichenfolgen
|
||||
```php
|
||||
$embed = $ai->embed();
|
||||
$candidates = ['How do I create a module?',
|
||||
'How can I make a new module?',
|
||||
'What is MMVC?'];
|
||||
|
||||
$vecs = $embed->batch($candidates);
|
||||
foreach ($vecs as $i => $a) {
|
||||
foreach ($vecs as $j => $b) {
|
||||
if ($i >= $j) continue;
|
||||
$sim = \Nibiru\Module\Ai\Plugin\Embed::cosine($a, $b);
|
||||
if ($sim > 0.9) {
|
||||
echo "Near-dup: {$candidates[$i]} ≈ {$candidates[$j]} ($sim)\n";
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
## Muster: semantische Markierung
|
||||
```php
|
||||
$tags = ['authentication', 'forms', 'database', 'modules'];
|
||||
$tagVecs = array_combine($tags, $embed->batch($tags));
|
||||
|
||||
function bestTag(string $text, array $tagVecs, $embed): string {
|
||||
$tv = $embed->one($text);
|
||||
$best = ['_unknown', -INF];
|
||||
foreach ($tagVecs as $tag => $vec) {
|
||||
$s = \Nibiru\Module\Ai\Plugin\Embed::cosine($tv, $vec);
|
||||
if ($s > $best[1]) $best = [$tag, $s];
|
||||
}
|
||||
return $best[0];
|
||||
}
|
||||
|
||||
echo bestTag('User::isAuthorized', $tagVecs, $embed); // → 'authentication'
|
||||
echo bestTag('Pageination::setTable', $tagVecs, $embed); // → 'database' (probably)
|
||||
```
|
||||
## Speicherung
|
||||
|
||||
Einbettungen sind Gleitkommazahlenarrays – typischerweise 768 Gleitkommazahlen für `nomic-embed-text` und 1024 für `mxbai-embed-large`. Das entspricht 3 KB oder 4 KB pro Vektor im Rohformat.
|
||||
|
||||
Verwenden Sie `Embed::pack()`, um sie als 4-Byte-Gleitkommazahlen in Base64 zu kodieren:
|
||||
```php
|
||||
$compact = Embed::pack($vec); // ~4 KB → ~5.3 KB base64 string
|
||||
$vec = Embed::unpack($compact);
|
||||
```
|
||||
Das RAG-Plugin verwendet dieses Format intern für seine JSON-Dateien.
|
||||
|
||||
## Auswahl der eingebetteten Modelle
|
||||
|
||||
Ziehen Sie auf neuronetz.ai einmal:
|
||||
```bash
|
||||
curl https://api.neuronetz.ai/api/pull -d '{"name":"nomic-embed-text"}' # 768 dim, default
|
||||
curl https://api.neuronetz.ai/api/pull -d '{"name":"mxbai-embed-large"}' # 1024 dim, higher quality
|
||||
```
|
||||
In `ai.ini`:
|
||||
```ini
|
||||
[AI]
|
||||
embed.model = "nomic-embed-text"
|
||||
embed.dim = 768
|
||||
```
|
||||
:::caution[Kombination von Modellen vermeiden]
|
||||
Vektoren aus `nomic-embed-text` und `mxbai-embed-large` befinden sich in unterschiedlichen geometrischen Räumen — der Kosinus zwischen ihnen ist ohne Bedeutung. Wenn Sie `embed.model` ändern, **setzen** Sie vor der Kombination jede RAG-Sammlung oder gespeicherten Embeddings zurück.
|
||||
:::
|
||||
|
||||
## Häufige Fallen
|
||||
|
||||
- **Aufrufen von `one()` in einer engen Schleife.** Jeder Aufruf ist eine HTTP-Roundtrip. Für >100 Elemente bevorzugen Sie `batch()` (immer noch seriell unter der Haube, aber mit konsistenter Fehlerbehandlung).
|
||||
- **Speichern roher Gleitkommazahlenarrays in JSON.** Verwenden Sie `pack()` für etwa 5-fach kleinere Dateien und schnellere Analyse.
|
||||
- **Vergleichen von Kosinus zu einem festen Schwellenwert.** Verschiedene Einbettungsmodelle haben unterschiedliche "ähnliche" Baseline-Werte. Vermeiden Sie die Festlegung auf 0,85 – kalibrieren Sie es je nach Modell.
|
||||
160
docs/src/content/docs/de/ai/module/overview.md
Normal file
160
docs/src/content/docs/de/ai/module/overview.md
Normal file
@@ -0,0 +1,160 @@
|
||||
---
|
||||
title: "Das KI-Modul"
|
||||
description: "Erster-Klasse KI auf Nibiru – Chat, Embeddings, RAG, Agenten – verbunden mit Ihrem eigenen Ollama auf neuronetz.ai. Keine bezahlten APIs erforderlich."
|
||||
---
|
||||
|
||||
Nibiru bietet ein **KIM-Modul** (`application/module/ai/`), das jeder Nibiru-Anwendung eine erstklassige KI-Oberfläche gibt. PHP-Code kann mit einem lokalen LLM chatten, Text einbetten, RAG über eigene Daten ausführen oder einen Agenten mit Tools starten – alles ohne einen Byte an eine bezahlte API zu senden.
|
||||
|
||||
Das Modul ist standardmäßig mit Ihrem eigenen [Ollama auf neuronetz.ai](/de/kuenstliche-intelligenz/orakel/) verbunden, sodass die Inferenz auf Ihrer Hardware, in Ihrem Netzwerk und Ihren Bedingungen erfolgt.
|
||||
|
||||
## Was Sie erhalten
|
||||
|
||||
| Plugin | Was es macht | Einzeiler |
|
||||
|---|---|---|
|
||||
| `Chat` | Chat-Vervollständigungen, ein- oder mehrfach | `$ai->chat()->ask('…')` |
|
||||
| `Embed` | Text → Vektoren + Kosinus-Hilfsprogramme | `$ai->embed()->one('…')` |
|
||||
| `Rag` | Aufnehmen + Abrufen + angebundener Chat | `$ai->rag('docs')->ask('…')` |
|
||||
| `Agent` | Werkzeugnutzender ReAct-Schleife | `$ai->agent()->withTools([…])->run('…')` |
|
||||
| `Tool` | Grundlage für Ihre eigenen benutzerdefinierten Tools | `class MyTool extends Tool { … }` |
|
||||
| `Ollama` | Roher HTTP-Transport zu jedem Ollama-kompatiblen Endpunkt | `(new Ollama($cfg))->chat(…)` |
|
||||
|
||||
## Hallo, KI
|
||||
```php
|
||||
use Nibiru\Module\Ai\Ai;
|
||||
|
||||
$ai = new Ai();
|
||||
|
||||
echo $ai->chat()->ask('How do I scaffold a new module?');
|
||||
// → "Run `./nibiru -m <name>`. This creates application/module/<name>/ with…"
|
||||
```
|
||||
Dies ist die gesamte API-Oberfläche für den einfachen Fall. Kein Dependency Injection-Container, keine API-Schlüssel, kein SDK-Installieren.
|
||||
|
||||
## Über Konfiguration verbunden
|
||||
|
||||
Jedes Plugin liest seine Einstellungen aus `application/module/ai/settings/ai.ini`:
|
||||
```ini
|
||||
[AI]
|
||||
ollama.base_url = "https://api.neuronetz.ai"
|
||||
chat.model = "nibiru-coder:1.0"
|
||||
chat.fallback_model = "qwen2.5-coder:14b"
|
||||
chat.temperature = 0.4
|
||||
chat.max_tokens = 1024
|
||||
embed.model = "nomic-embed-text"
|
||||
rag.top_k = 6
|
||||
agent.max_iterations = 6
|
||||
```
|
||||
Umgebungsspezifische Überschreibungen: `ai.production.ini`, `ai.staging.ini`. Der Nibiru-Registrierungsmechanismus erkennt diese automatisch.
|
||||
|
||||
## Die vier Hauptanwendungsfälle
|
||||
|
||||
### 1. Chat — sprechen Sie mit Ihrem Modell
|
||||
```php
|
||||
$ai = new \Nibiru\Module\Ai\Ai();
|
||||
|
||||
// One-shot
|
||||
echo $ai->chat()->ask('Explain MMVC in two sentences.');
|
||||
|
||||
// Multi-turn
|
||||
$chat = $ai->chat();
|
||||
$chat->user('How do I scaffold a module?');
|
||||
$chat->user('And add Graylog hooks?'); // referrs to previous turn
|
||||
echo $chat->complete();
|
||||
|
||||
// Override per call
|
||||
echo $ai->chat()
|
||||
->system('Answer in German.')
|
||||
->model('qwen2.5-coder:14b')
|
||||
->temperature(0.1)
|
||||
->ask('Was ist ein Modul?');
|
||||
```
|
||||
Der `Chat`-Plugin wechselt automatisch zu `chat.fallback_model`, wenn das primäre Modell nicht verfügbar ist – nützlich, während Sie noch an `nibiru-coder` arbeiten.
|
||||
|
||||
### 2. Einbetten — Text in Vektoren
|
||||
```php
|
||||
$embed = $ai->embed();
|
||||
|
||||
$va = $embed->one('controller');
|
||||
$vb = $embed->one('module');
|
||||
$score = \Nibiru\Module\Ai\Plugin\Embed::cosine($va, $vb);
|
||||
// → 0.78 (close concepts)
|
||||
```
|
||||
Kompakte Speicherung:
|
||||
```php
|
||||
$packed = \Nibiru\Module\Ai\Plugin\Embed::pack($vec); // base64 string, 4 bytes/dim
|
||||
$vec = \Nibiru\Module\Ai\Plugin\Embed::unpack($packed);
|
||||
```
|
||||
### 3. RAG — Ingestieren, Abrufen, Verankern
|
||||
```php
|
||||
$rag = $ai->rag('product-help');
|
||||
|
||||
// One-time ingestion
|
||||
$rag->ingestDir(__DIR__ . '/help/'); // walks .md/.txt/.php
|
||||
$rag->ingestText('FAQ entry…', ['source' => 'faq-12']);
|
||||
$rag->ingestFile('/var/data/manual.pdf.txt');
|
||||
|
||||
// Then ask grounded questions
|
||||
echo $rag->ask('How do I cancel my subscription?');
|
||||
// → "Per the help docs, you can cancel in account → settings… [1]"
|
||||
```
|
||||
Speicherung: Eine einzelne JSON-Datei pro Sammlung unter `application/module/ai/cache/rag/<name>.json`. Wiederholbar, ohne Datenbank, passt bequem ~10.000 Chunks im Speicher.
|
||||
|
||||
### 4. Agent — Werkzeuge, die handeln
|
||||
```php
|
||||
use Nibiru\Module\Ai\Plugin\Tools;
|
||||
|
||||
$ai = new \Nibiru\Module\Ai\Ai();
|
||||
|
||||
$agent = $ai->agent()->withTools([
|
||||
new Tools\PdoQuery(), // read-only SQL
|
||||
new Tools\HttpGet(), // fetch URLs
|
||||
new Tools\FileRead(), // read project files
|
||||
]);
|
||||
|
||||
echo $agent->run('How many active users registered last week?');
|
||||
// → agent decides to call pdo_query with SELECT count(*) FROM users…
|
||||
// reads observation, writes a final answer.
|
||||
```
|
||||
Der Agent verwendet eine ReAct-stilige Schleife: Lesen der Aufgabe → Auswahl des Tools → Ausführen → Beobachten → Wiederholen → Endantwort. Das Protokoll verwendet einen einfachen JSON-Sentinel `\`\`\`tool {...}\`\`\`` , der bei jedem Ollama-Modell funktioniert – keine modellspezifischen Tool-Aufruf-APIs erforderlich.
|
||||
|
||||
## Wo es lebt
|
||||
```
|
||||
application/module/ai/
|
||||
├── ai.php # main class implementing IModule
|
||||
├── interfaces/ai.php # contract
|
||||
├── traits/ai.php # cfg() helper
|
||||
├── plugins/
|
||||
│ ├── ollama.php # raw transport
|
||||
│ ├── chat.php # chat completions
|
||||
│ ├── embed.php # embeddings + cosine + pack
|
||||
│ ├── rag.php # ingest + retrieve + grounded chat
|
||||
│ ├── agent.php # ReAct tool loop
|
||||
│ ├── tool.php # abstract base for custom tools
|
||||
│ └── tools/
|
||||
│ ├── pdoQuery.php # read-only SQL
|
||||
│ ├── httpGet.php # HTTP GET
|
||||
│ └── fileRead.php # project-local file read
|
||||
├── settings/ai.ini # config
|
||||
├── cache/rag/ # RAG vector index files (gitignored)
|
||||
└── training/
|
||||
├── Modelfile # the nibiru-coder system prompt
|
||||
├── build.sh # one-command Modelfile → registered model
|
||||
├── smoke-test.php # verify the whole stack
|
||||
└── README.md # training pipeline guide
|
||||
```
|
||||
## Warum dies existiert
|
||||
|
||||
PHP hat keine etablierte "Künstliche-Intelligenz-Frameworks" wie Python mit LangChain oder JS mit Vercel AI SDK. Das Nibiru-AI-Modul füllt diesen Lücke mit der kleinsten und schärfsten API, die wir schreiben konnten – drei Ebenen (Transport → Plugin → Modul), kein DI-Grafik, keine SDK-Installation, keine pro-Token-Rechnung.
|
||||
|
||||
Die Gestaltungsentfernung:
|
||||
|
||||
- **Bring your own brain.** Ollama ist standardmäßig aktiviert, Anthropic und OpenAI können als Ersatzmodule verwendet werden. Providerwechsel erfolgt über INI-Dateien, nicht über den Code.
|
||||
- **Eine JSON-Datei pro RAG-Sammlung.** Keine Vektordatenbank erforderlich. Wiederherstellungsicher nach einem Neustart. Grep-fähig bei der Fehlerbehebung.
|
||||
- **Tools sind PHP-Klassen.** Erweitern Sie `Tool`, erhalten Sie einen Namen + Schema + eine Ausführungsmethode. Der Agent kümmert sich um den Rest.
|
||||
- **Keine modellspezifischen Tool-Aufruf-APIs.** Eine einzige eingerahmte JSON-Konvention funktioniert überall.
|
||||
|
||||
## Nächste
|
||||
|
||||
- [Chat-Plugin-Referenz](/de/kI/modul/chat/)
|
||||
- [RAG-Plugin-Referenz](/de/kI/modul/rag/)
|
||||
- [Agent-Plugin-Referenz](/de/kI/modul/agent/)
|
||||
- [Training nibiru-coder](/de/kI/modul/training/)
|
||||
99
docs/src/content/docs/de/ai/module/rag.md
Normal file
99
docs/src/content/docs/de/ai/module/rag.md
Normal file
@@ -0,0 +1,99 @@
|
||||
---
|
||||
title: "RAG-Erweiterung"
|
||||
description: "Text einlesen, eingebettet, Top-K abrufen und fundierte Fragen beantworten – alles in einer PHP-Klasse."
|
||||
---
|
||||
|
||||
Das RAG-Plugin ist die Killerfunktion des KI-Moduls für Produktbauer. Es verwandelt jede Menge Text – Ihre Hilfedokumentationen, Ihre Fehlerprotokolle, Ihre Stripe-Rechnungen, Ihre Kundensupport-Tickets – in eine abfragbare Wissensbasis in etwa vier Zeilen PHP.
|
||||
|
||||
## Drei Minuten, von Anfang bis Ende
|
||||
```php
|
||||
use Nibiru\Module\Ai\Ai;
|
||||
|
||||
$ai = new Ai();
|
||||
$rag = $ai->rag('product-help'); // a named collection
|
||||
|
||||
$rag->ingestDir(__DIR__ . '/help/'); // walks .md/.txt/.php under help/
|
||||
$rag->ingestText('FAQ entry…', ['source' => 'faq-12']);
|
||||
|
||||
echo $rag->ask('How do I cancel my subscription?');
|
||||
// → grounded answer, citing chunks like [1] [2] [3]
|
||||
```
|
||||
Das ist alles. Keine Vektordatenbank. Kein SDK. Kein Python-Sidecar.
|
||||
|
||||
## Wie es funktioniert
|
||||
```
|
||||
ingestText / ingestFile / ingestDir
|
||||
↓
|
||||
chunk → embed (Ollama nomic-embed-text)
|
||||
↓
|
||||
pack vectors → JSON file at cache/rag/<collection>.json
|
||||
↓
|
||||
ask(question) → embed question → cosine top-K → chat with chunks as context
|
||||
```
|
||||
Speicherung erfolgt durch eine JSON-Datei pro Sammlung. Jeder Chunk ist ein Objekt mit `text` und `metadata`; Vektoren sind als base64-gepackte Float32Array gespeichert – etwa 3 KB pro Chunk. ~10.000 Chunks passen komfortabel in den Speicher.
|
||||
|
||||
## Mehrere Sammlungen
|
||||
|
||||
Sie können eine beliebige Anzahl von Sammlungen in der gleichen App haben. Jede hat ihre eigene JSON-Datei. Sie teilen das Einbettungsmodell und das Chatmodell aus der `[AI]`-Konfiguration.
|
||||
```php
|
||||
$docs = $ai->rag('docs');
|
||||
$tickets = $ai->rag('support-tickets');
|
||||
$logs = $ai->rag('error-logs');
|
||||
|
||||
$docs->ingestDir(__DIR__ . '/help/');
|
||||
$tickets->ingestText($ticket->body, ['ticket_id' => $ticket->id]);
|
||||
$logs->ingestText($exception->__toString(), ['ts' => time()]);
|
||||
```
|
||||
## API-Referenz
|
||||
```php
|
||||
$rag = $ai->rag('name'); // get/create a named collection
|
||||
|
||||
// --- Ingestion ---
|
||||
$rag->ingestText($text, $metadata = []); // single chunk
|
||||
$count = $rag->ingestFile('path'); // returns chunks added
|
||||
$count = $rag->ingestDir('dir', ['md','txt','php']); // recursive
|
||||
|
||||
// --- Querying ---
|
||||
$hits = $rag->search('query', $k = null); // [{score, text, metadata}, …]
|
||||
$answer = $rag->ask('question', $k = null); // top-K → chat call
|
||||
|
||||
// --- Maintenance ---
|
||||
$rag->reset(); // forget everything (deletes file)
|
||||
$n = $rag->size(); // number of chunks
|
||||
```
|
||||
## Einstellungsregler
|
||||
|
||||
In `application/module/ai/settings/ai.ini`:
|
||||
```ini
|
||||
[AI]
|
||||
embed.model = "nomic-embed-text" ; or mxbai-embed-large for higher quality
|
||||
rag.top_k = 6 ; chunks injected into the chat call
|
||||
rag.chunk_target = 600 ; tokens per chunk (target)
|
||||
rag.chunk_min = 120 ; smaller chunks merged
|
||||
rag.chunk_max = 900 ; larger paragraphs split on sentences
|
||||
rag.storage_path = "/../../application/module/ai/cache/rag/"
|
||||
```
|
||||
## Wann es verwendet werden sollte
|
||||
|
||||
- **Hilfe / FAQ Chat** — Laden Sie Ihre Hilfesätze ein und stellen Sie einen `/ask` Endpunkt zur Verfügung.
|
||||
- **In-app Code-Suche** — Laden Sie `application/module/` ein und fragen Sie sich "Wo berechnen wir die Mehrwertsteuer?"
|
||||
- **Assistent für interne Dokumente** — Laden Sie den Wiki-Dump Ihres Teams ein.
|
||||
- **Kundengeschichtensuchungen** — Laden Sie Tickets ein und fragen Sie sich "Haben wir diesen Fehler schon einmal gesehen?"
|
||||
|
||||
## Wann es NICHT verwendet werden sollte
|
||||
|
||||
- **Echtzeit, schreibintensive Daten** — RAG ist ein Snapshot. Für lebende Daten schreiben Sie ein [Tool](/en/ai/module/agent/), das der Agent aufrufen kann.
|
||||
- **Massive Korpora (> 100k Chunks)** — Die Speicherung in JSON-Dateien beginnt zu knarzen. Wechseln Sie zu Qdrant / pgvector / Weaviate; wir veröffentlichen einen Adapter, sobald wir einen für uns selbst benötigen.
|
||||
- **Alles, wo Sie *genaue* Antworten benötigen und nicht nur *wahrscheinliche* Antworten.** RAG ist probabilistisch. Verwenden Sie es nicht als Datenbankabfrageebene.
|
||||
|
||||
## Häufige Fallen
|
||||
|
||||
- **`nomic-embed-text` wurde nicht abgerufen.** Der erste Aufruf von `ingestText` schlägt mit einem klaren Fehler fehl, der Sie auf den Pull-Befehl hinweist.
|
||||
- **Modellkonflikt beim Einbetten.** Verwenden Sie keine `nomic-embed-text`-Blöcke zusammen mit `mxbai-embed-large`-Abfragen – unterschiedliche Vektorräume. Wenn Sie `embed.model` ändern, führen Sie zuerst `$rag->reset()` aus.
|
||||
- **Veraltete Sammlungen.** Das erneute Ausführen von `ingestDir` entfernt keine Duplikate. Verwenden Sie `reset()` und fügen Sie dann erneut ein, oder überprüfen Sie selbst eine Inhalts-Hash-Erkennung.
|
||||
- **Kleine Blöcke.** Unter etwa 80 Token werden die Einbettungen störend. Der Standardwert von `rag.chunk_min = 120` führt kleine benachbarte Blöcke zusammen.
|
||||
|
||||
## Was kommt als nächstes?
|
||||
|
||||
- [Agent-Plugin →](/de/kI/modul/agent/) für Werkzeuge, nicht für Abruf.
|
||||
- [Trainingsnibiru-coder →](/de/kI/modul/trainings/) um den Chat so zu gestalten, dass er halb in der Stimme des Frameworks antwortet.
|
||||
111
docs/src/content/docs/de/ai/module/training.md
Normal file
111
docs/src/content/docs/de/ai/module/training.md
Normal file
@@ -0,0 +1,111 @@
|
||||
---
|
||||
title: "Training nibiru-coder"
|
||||
description: "Wie Sie ein Nibiru-gesmacktes Chatmodell auf Ihrem eigenen Ollama registrieren. Eine Modelldatei, ein Shell-Skript, sechzig Sekunden."
|
||||
---
|
||||
|
||||
Das Standard-Chatmodell des Frameworks ist **`nibiru-coder:1.0`** — ein Nibiru-geschnittenes Qwen 2.5 Coder 14B, das Sie auf Ihrem Ollama-Server registrieren. Der Trainingspipeline befindet sich im Verzeichnis `application/module/ai/training/`.
|
||||
|
||||
## Was `nibiru-coder` ist (und nicht ist)
|
||||
|
||||
`nibiru-coder:1.0` ist **kein** LoRA Fine-Tuning. Es sind die gleichen Gewichte von `qwen2.5-coder:14b`, verpackt mit einem eingebauten System-Prompt, der:
|
||||
|
||||
- erläutert MMVC, Module, den Dispatcher und die Singletons,
|
||||
- erzwingt die Konventionen von Nibiru (`pageAction`, `navigationAction`, `View::assign`, `Form::create`, die Schreibweise von `Pageination`),
|
||||
- fördert das Modell zu Nibiru-idiomatischen Antworten anstelle allgemeiner Laravel / Symfony-Ratschläge.
|
||||
|
||||
Die Anpassung des System-Prompts erfolgt **unmittelbar** — keine GPU-Training, keine Datensatz-Vorbereitung erforderlich. Es bietet ungefähr 80 % der Wertschöpfung einer echten LoRA bei null Trainingskosten. Wenn Sie ein Budget für eine echte Feinabstimmung haben, siehe unten *Echte LoRA-Pfad*.
|
||||
|
||||
## Bauen Sie es
|
||||
```bash
|
||||
./application/module/ai/training/build.sh # builds nibiru-coder:1.0
|
||||
./application/module/ai/training/build.sh 1.1 # bump tag for iterations
|
||||
```
|
||||
Das Skript:
|
||||
|
||||
1. Liest die Datei `Modelfile` neben sich.
|
||||
2. Sendet eine POST-Anfrage an `${OLLAMA_BASE_URL}/api/create` (Standardwert `https://api.neuronetz.ai`).
|
||||
3. Führt einen Rauchtest-Chat-Aufruf durch, um zu bestätigen, dass der neue Tag antwortet.
|
||||
|
||||
Nachdem es erfolgreich ist, setzen Sie das Modell in `application/module/ai/settings/ai.ini`:
|
||||
```ini
|
||||
[AI]
|
||||
chat.model = "nibiru-coder:1.0"
|
||||
chat.fallback_model = "qwen2.5-coder:14b"
|
||||
```
|
||||
…und jede Instanz von `\Nibiru\Module\Ai\Ai` in Ihrer Anwendung kommuniziert mit ihr. Der Fallback stellt sicher, dass nichts bricht, wenn Sie den Tag noch nicht erstellt haben.
|
||||
|
||||
## Arbeiten Sie an dem System-Prompt weiter
|
||||
|
||||
Der `SYSTEM """ ... """`-Block im Modelfile ist der Schlüssel. Verfestigen Sie die Konventionen, fügen Sie neue Beispiele hinzu und verweisen Sie auf spezifische Framework-Dateien. Führen Sie `build.sh` erneut mit einem neuen Tag (`1.1`, `1.2`, …) aus und führen Sie eine A/B-Vergleichsphase im Vergleich zum vorherigen Tag in Ihrer Anwendung durch.
|
||||
```bash
|
||||
./application/module/ai/training/build.sh 1.1
|
||||
# Edit ai.ini → chat.model = "nibiru-coder:1.1"
|
||||
# Compare answers in the Oracle widget or via smoke-test.php
|
||||
```
|
||||
## Echte LoRA-Pfad
|
||||
|
||||
Wenn Sie ein Modell benötigen, dessen **Gewichte** Nibiru kennen – nicht nur sein System-Prompt – bietet Ihnen der Korpus-Exporter die Lösung.
|
||||
```bash
|
||||
cd docs
|
||||
npm run build:corpus
|
||||
```
|
||||
Generiert JSONL-Dateien unter `dist/corpus/`:
|
||||
|
||||
| Datei | Format | Verwendung |
|
||||
|---|---|---|
|
||||
| `chat.jsonl` | sharegpt-stilige Nachrichten | Axolotl, LLaMA-Factory, Unsloth |
|
||||
| `instructions.jsonl` | Anweisung/Eingabe/Ausgabe | Alpaca-artige Trainer |
|
||||
| `completion.jsonl` | Aufforderung/Ergebnis | Legacy Textabschluss Feinabstimmungen |
|
||||
| `chunks.jsonl` | Chunk-Metadaten | RAG / Evaluierungsset-Konstruktion |
|
||||
|
||||
Ein praktisches Rezept für eine 8B-Basis auf einem einzelnen A100 / 4090:
|
||||
```yaml
|
||||
# axolotl.yml
|
||||
base_model: meta-llama/Llama-3.1-8B-Instruct
|
||||
adapter: lora
|
||||
lora_r: 16
|
||||
lora_alpha: 32
|
||||
lora_dropout: 0.05
|
||||
lora_target_modules: [q_proj, k_proj, v_proj, o_proj]
|
||||
|
||||
datasets:
|
||||
- path: docs/dist/corpus/chat.jsonl
|
||||
type: sharegpt
|
||||
|
||||
sequence_len: 4096
|
||||
sample_packing: true
|
||||
gradient_accumulation_steps: 4
|
||||
micro_batch_size: 2
|
||||
num_epochs: 3
|
||||
optimizer: adamw_bnb_8bit
|
||||
learning_rate: 0.0002
|
||||
warmup_ratio: 0.05
|
||||
bf16: true
|
||||
```
|
||||
Nach dem Training:
|
||||
|
||||
1. Konvertieren Sie das LoRA in GGUF (`llama.cpp`'s `convert_hf_to_gguf.py`).
|
||||
2. Erstellen Sie eine Ollama Modelfile mit `FROM ./your-lora.gguf`.
|
||||
3. Führen Sie `./build.sh 2.0` aus, um es als `nibiru-coder:2.0` zu registrieren.
|
||||
|
||||
Der Framework-Code ändert sich nicht — ändern Sie `chat.model` in `ai.ini`, und Sie verwenden die neuen Gewichte.
|
||||
|
||||
## Rauchtest
|
||||
```bash
|
||||
php application/module/ai/training/smoke-test.php
|
||||
```
|
||||
Überprüft:
|
||||
|
||||
- Der Ollama-Server ist erreichbar.
|
||||
- Das Modell reagiert auf eine Einzelschritt-Anfrage.
|
||||
- Mehrschrittige Konversationskontexte funktionieren.
|
||||
- Embeddings funktionieren (wenn `nomic-embed-text` nicht gepullt wird, wird eine klare Nachricht ausgegeben).
|
||||
|
||||
Führen Sie nach jeder Änderung der Datei `Modelfile` aus, bevor Sie bereitstellen.
|
||||
|
||||
## Häufige Fallen
|
||||
|
||||
- **`Modelfile` System-Prompt ist zu lang.** Einige Ollama-Versionen begrenzen System-Prompts. Halten Sie es unter etwa 3000 Token.
|
||||
- **Vergessen des FROM-Modells.** `qwen2.5-coder:14b` muss bereits auf dem Server vorhanden sein. Überprüfen Sie mit `curl ${OLLAMA_BASE_URL}/api/tags`.
|
||||
- **Tag-Kollisionen.** Das erneute Ausführen von `build.sh 1.0` überschreibt das bestehende `nibiru-coder:1.0`. Verwenden Sie für Iteration neue Tags; fixieren Sie spezifische Tags in `ai.ini` für die Produktion.
|
||||
- **Verwirrung mit `--no-stream`.** Das Build-Skript verwendet `stream: false`, sodass die Antwort als ein JSON zurückkommt. Wenn Sie zu einem gestreamten Modus wechseln, analysieren Sie Zeile für Zeile.
|
||||
112
docs/src/content/docs/de/ai/oracle.md
Normal file
112
docs/src/content/docs/de/ai/oracle.md
Normal file
@@ -0,0 +1,112 @@
|
||||
---
|
||||
title: "Frage die Oracle"
|
||||
description: "Wie der eingebettete KI-Assistent funktioniert – RAG über die Dokumentationen, bereitgestellt durch Ihren eigenen Ollama auf neuronetz.ai."
|
||||
---
|
||||
|
||||
Die orange Schaltfläche in der Ecke jeder Seite ist der **Nibiru Oracle** – ein KI-Assistent, der auf dieser Dokumentation basiert. Fragen Sie ihn nach Routing, Modulen, der CLI, der Smarty-Schicht oder der Bedeutung von `pageAction()`. Er zitiert seine Quellen.
|
||||
|
||||
## Was steuert es
|
||||
|
||||
Standardmäßig läuft der Oracle vollständig auf **Ihren eigenen Infrastrukturen**.
|
||||
|
||||
| Ebene | Backend | Standardmodell |
|
||||
|---|---|---|
|
||||
| Chat (Antwortgenerierung) | Ollama auf `https://api.neuronetz.ai` | `qwen2.5-coder:14b` |
|
||||
| Embeddings (RAG-Retrieval) | Ollama auf `https://api.neuronetz.ai` | `nomic-embed-text` |
|
||||
|
||||
Keine bezahlten API-Schlüssel. Ihre Daten verlassen Ihr Netzwerk nicht. Der 5-GPU-Cluster von Ollama, den Sie bereits betreiben, übernimmt die Last.
|
||||
|
||||
Wenn Sie lieber einen bezahlten Anbieter verwenden möchten – Claude für Chats und OpenAI für Embeddings – setzen Sie `LLM_PROVIDER=anthropic` und/oder `EMBED_PROVIDER=openai` sowie die entsprechenden API-Schlüssel. Die Codepfade sind identisch.
|
||||
|
||||
## Wie es funktioniert
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[User question] --> B[Embed via Ollama<br/>nomic-embed-text]
|
||||
B --> C[Cosine search<br/>against pre-computed<br/>doc-chunk index]
|
||||
C --> D[Top-K chunks]
|
||||
D --> E[Ollama chat<br/>qwen2.5-coder:14b<br/>system + retrieved context]
|
||||
E --> F[Answer + source list]
|
||||
F --> G[Render in chat UI]
|
||||
```
|
||||
1. **Beim Build-Zeitpunkt** durchläuft die Dokumentationssite jede Markdown-Seite, teilt sie an den Grenzen von H2/H3 in etwa 600-Token-Blöcken auf, bettet jedes Blatt mit `nomic-embed-text` ein und schreibt das Ergebnis in `public/oracle-index.json`. Keine Datenbank erforderlich.
|
||||
2. **Beim Anfrage-Zeitpunkt** wird die Frage des Benutzers auf dieselbe Weise eingebettet, die nächsten Blöcke werden durch Kosinusähnlichkeit abgerufen und zu einem System-Prompt für das Chat-Modell zusammengefügt.
|
||||
3. **Antwortet das Chat-Modell** im Sprach des Benutzers und bezieht sich auf die Quellenblöcke per URL.
|
||||
|
||||
## Dateien
|
||||
|
||||
| Datei | Zweck |
|
||||
|---|---|
|
||||
| `scripts/lib/providers.mjs` | Gemeinsamer Chat- und Embedding-Adapter (Ollama / Anthropic / OpenAI). |
|
||||
| `scripts/build-oracle-index.mjs` | Erstellt `public/oracle-index.json` zur Build-Zeit. |
|
||||
| `public/oracle-index.json` | Der eingeführte/Build-Ausgabe-Embedding-Index. |
|
||||
| `src/pages/api/oracle.ts` | Der SSR-Endpunkt, an den das Chat-Widget POSTs. Bietet auch einen GET für Diagnosen. |
|
||||
| `src/components/CosmicHeader.astro` | Der schwebende Launcher + Chat-Benutzeroberfläche. |
|
||||
|
||||
## Einmalige Einrichtung auf neuronetz.ai
|
||||
|
||||
Ziehen Sie die beiden Modelle herunter, die der Oracle verwendet:
|
||||
```bash
|
||||
curl https://api.neuronetz.ai/api/pull -d '{"name":"qwen2.5-coder:14b"}'
|
||||
curl https://api.neuronetz.ai/api/pull -d '{"name":"nomic-embed-text"}'
|
||||
```
|
||||
`qwen2.5-coder:14b` ist bereits installiert (lebt getestet). `nomic-embed-text` fehlt noch; ohne es läuft der Oracle im Chat-Modus (ohne RAG).
|
||||
|
||||
## Konfiguration durchführen
|
||||
|
||||
Der Oracle liest seine Konfiguration aus Umgebungsvariablen. Sinnvolle Standardwerte sind integriert.
|
||||
```bash
|
||||
# Default mode (Ollama on neuronetz.ai)
|
||||
LLM_PROVIDER=ollama # default
|
||||
OLLAMA_BASE_URL=https://api.neuronetz.ai # default
|
||||
OLLAMA_CHAT_MODEL=qwen2.5-coder:14b # default
|
||||
OLLAMA_EMBED_MODEL=nomic-embed-text # default
|
||||
|
||||
# Optional fallbacks
|
||||
LLM_PROVIDER=anthropic
|
||||
ANTHROPIC_API_KEY=sk-ant-...
|
||||
ANTHROPIC_MODEL=claude-haiku-4-5-20251001
|
||||
|
||||
EMBED_PROVIDER=openai
|
||||
OPENAI_API_KEY=sk-...
|
||||
OPENAI_EMBED_MODEL=text-embedding-3-small
|
||||
|
||||
# Behaviour
|
||||
ORACLE_TOP_K=6
|
||||
ORACLE_MAX_TOKENS=800
|
||||
```
|
||||
## Diagnose-Endpunkt
|
||||
|
||||
`GET /api/oracle` gibt die aktuelle Konfiguration zurück (keine Geheimnisse):
|
||||
```bash
|
||||
curl https://nibiru-framework.com/api/oracle
|
||||
{
|
||||
"status": "ok",
|
||||
"llm": { "provider": "ollama", "ollamaUrl": "https://api.neuronetz.ai",
|
||||
"model": "qwen2.5-coder:14b" },
|
||||
"embed": { "provider": "ollama", "ollamaUrl": "https://api.neuronetz.ai",
|
||||
"model": "nomic-embed-text" },
|
||||
"index": { "present": true, "chunks": 177,
|
||||
"provider": "ollama", "model": "nomic-embed-text" }
|
||||
}
|
||||
```
|
||||
Nützlich zur Überprüfung, ob ein neu bereitgestellter Container den Back-End-Dienst verwendet, den Sie erwartet haben.
|
||||
|
||||
## Datenschutz
|
||||
|
||||
- Fragen und Gesprächshistorie werden an Ihren Ollama-Server gesendet. Sie werden **nicht** vom Docs-Site oder von Anthropic/OpenAI im Standard-Ollama-Konfiguration gespeichert.
|
||||
- Der OpenAI-Schlüssel (falls verwendet) wird nur für Embeddings aufgerufen.
|
||||
- Das Oracle-Widget selbst setzt keine Analysen oder Cookies.
|
||||
|
||||
## Warum ein von Nibiru trainiertes Modell?
|
||||
|
||||
Die Roadmap (siehe [AI Roadmap](/en/ai/roadmap/)) besteht darin, eine LoRA auf der Exportdatei des [Trainingskorpus](/en/ai/corpus/) zu feinabzustimmen, sodass das Chatmodell selbst Nibiru-nativ ist. Sobald dies bereitgestellt ist, wird die `OLLAMA_CHAT_MODEL` des Orakels auf das feinabgestimmte Modell umgeschaltet und der Systemprompt vereinfacht. Gleiches Code, intelligentere Antworten.
|
||||
|
||||
## Try it
|
||||
|
||||
Öffnen Sie den Oracle (die gelbe Planeten, unten rechts) und versuchen Sie eines dieser Optionen:
|
||||
|
||||
- *"Wie erstelle ich ein neues Modul?"*
|
||||
- *"Was macht `pageAction`?"*
|
||||
- *"Zeige mir, wie ich einen JSON-Endpunkt verwalte."*
|
||||
- *"Wie schreibe ich eine Migration?"* (Deutsch funktioniert.)
|
||||
- *"Authentifizierungsfluss erläutern"* (Japanisch funktioniert.)
|
||||
55
docs/src/content/docs/de/ai/roadmap.md
Normal file
55
docs/src/content/docs/de/ai/roadmap.md
Normal file
@@ -0,0 +1,55 @@
|
||||
---
|
||||
title: "AI Roadmap"
|
||||
description: "Wo die KI-Integration von Nibiru hingehört – der Plan, uns vom auf RAG-basierenden Oracle zu einem fein abgestimmten LoRA in der Produktion zu bringen."
|
||||
---
|
||||
|
||||
Nibiru's Ambition: Sei das **erste PHP-Framework mit einem fein abgestimmten Modell, das auf eigene Kenntnisse trainiert wurde**, das als erstklassiger Bestandteil der Entwicklererfahrung angeboten wird. Diese Seite verfolgt die Schritte.
|
||||
|
||||
## Phase 1 — Heute: RAG Oracle ✓
|
||||
|
||||
- [x] Markdown-Segmentierer mit H2/H3-Grenzen.
|
||||
- [x] OpenAI-Einbettungen (`text-embedding-3-small`).
|
||||
- [x] Vektorindex als einzelne JSON-Datei.
|
||||
- [x] Astro-Endpunkt `/api/oracle`, der Claude mit abgeruftem Kontext aufruft.
|
||||
- [x] Gleitender Chat-Widget auf jeder Dokumentationsseite.
|
||||
- [x] Mehrsprachig (EN/DE/JA/ES/FR) Eingabe + Ausgabe.
|
||||
|
||||
**Warum zuerst.** RAG funktioniert ohne Training, skaliert linear mit der Inhaltsgröße und ist sehr günstig. Jede Dokumentenbearbeitung verbessert die Antwortqualität in derselben Stunde.
|
||||
|
||||
## Phase 2 — Nächster Schritt: Öffentlicher Korpus + LoRA Rezept
|
||||
|
||||
- [ ] `npm run build:corpus` wird in `main` bereitgestellt (Anleitungen/Chat/Ausschnitte JSONL).
|
||||
- [ ] Veröffentlichung des Hugging Face-Datasets (`nibiru-framework/docs-corpus`).
|
||||
- [ ] Referenzieren Sie die Axolotl-YAML für Llama 3.1 8B.
|
||||
- [ ] Referenzieren Sie die Rezepte für Qwen 2.5 7B und Mistral Nemo 12B.
|
||||
- [ ] Bewertungsmenge: 200 von Hand bewertete Nibiru-Fragen mit goldenen Antworten.
|
||||
|
||||
**Warum zweitens.** Sobald das Korpus aus den Dokumenten reproduzibel ist, kann jeder trainieren. Wir behandeln die Dokumente als Quelle der Wahrheit und das Korpus als abgeleitetes Artefakt.
|
||||
|
||||
## Phase 3 — Dann: Gehosteter LoRA-Endpunkt
|
||||
|
||||
- [ ] Trainen Sie einen ersten Durchgang von LoRA auf dem öffentlichen Korpus.
|
||||
- [ ] Bereitstellen Sie über vLLM hinter `/api/oracle` mit einem Feature-Flag.
|
||||
- [ ] Seite an Seite Benutzeroberfläche zum Vergleich zwischen Claude (RAG) und LoRA (ohne RAG) sowie LoRA + RAG.
|
||||
- [ ] Telemetrie: Welches Formular bevorzugt der Benutzer je nach Frageart?
|
||||
|
||||
**Warum drittens.** Eine Seite-an-Seite-Vergleich zeigt, wo die LoRA hilft (idiomatischer Nibiru-Stil) und wo sie schadet (sehr langer Kontext, frische Bearbeitungen sind noch nicht neu trainiert).
|
||||
|
||||
## Phase 4 — Schließlich: Editor-Agents
|
||||
|
||||
- [ ] PHPStorm-Erweiterung: markieren Sie einen Controller und fragen Sie den Oracle, ihn in ein Modul umzuwandeln.
|
||||
- [ ] CLI-Agent: `./nibiru ask "diese Kontroller als JSON-Endpunkt umschreiben"`.
|
||||
- [ ] PR-Review-Bot: erklären Sie Nibiru-spezifische Abweichungen in Pull Requests auf Framework-Forks.
|
||||
|
||||
## Phase 5 — Aspirational: Aktives Lernen
|
||||
|
||||
- [ ] Benutzerfeedback im Chat-Widget (👍 / 👎) schreibt eine Zeile in ein privates Dataset.
|
||||
- [ ] Die wöchentliche Überprüfungswarteschlange hebt niedrig bewertete Antworten für menschliche Anmerkungen hervor.
|
||||
- [ ] Verbesserte Antworten gelangen wieder ins Korpus im nächsten Trainingszyklus.
|
||||
|
||||
## Wie Sie helfen können
|
||||
|
||||
- **Stellen Sie den Oracle schwierige Fragen** und bewerten Sie die Antworten.
|
||||
- **Öffnen Sie Issues** im [GitHub Repo](https://github.com/alllinux/Nibiru) für fehlende Themen.
|
||||
- **Tragen Sie Übersetzungen bei** — jede übersetzte Dokumentenseite ist auch eine Zeile des parallelen Korpus.
|
||||
- **Probieren Sie eine LoRA Fine-Tuning** auf dem veröffentlichten Korpus und teilen Sie die Ergebnisse.
|
||||
57
docs/src/content/docs/de/cli/cms.md
Normal file
57
docs/src/content/docs/de/cli/cms.md
Normal file
@@ -0,0 +1,57 @@
|
||||
---
|
||||
title: "CMS-Seiten (CLI)"
|
||||
description: "Erstellen und Löschen von CMS-Seiten über die Befehlszeile."
|
||||
---
|
||||
|
||||
Wenn das `cms`-Modul installiert ist, erhält die Nibiru CLI zwei zusätzliche Flags für die direkte Verwaltung von CMS-Seiten. Dies ist der gleiche Inhaltsspeicher, der den Produktions-E-Commerce-Site `prod.maschinen-stockert.de` betreibt, wo Redakteure Site-Copy aktualisieren können, ohne Code zu berühren.
|
||||
|
||||
## Seite erstellen
|
||||
```bash
|
||||
./nibiru -new-cms-page about-us
|
||||
```
|
||||
Dies:
|
||||
|
||||
1. Fügt eine Zeile in die Tabelle `cms_pages` mit dem Slug `about-us` ein.
|
||||
2. Bindet die Seite an eine CMS-Vorlage (die Standardvorlage, es sei denn, eine andere ist konfiguriert).
|
||||
3. Erstellt pro Sprache Platzhalterzeilen in der Tabelle `cms_template_texts`, sodass Redakteure Texte in jeder unterstützten Sprache eingeben können.
|
||||
|
||||
Besuchen Sie `/cms/about-us` (oder Ihren konfigurierten CMS-Prefix) und die neue Seite ist aktiv.
|
||||
|
||||
## Seite löschen
|
||||
```bash
|
||||
./nibiru -delete-cms-page about-us
|
||||
```
|
||||
Entfernt die Seitenzeile und ihre zugehörigen `cms_template_texts` Einträge. Die eigentliche CMS-Vorlage wird nicht gelöscht – nur die Verknüpfung der Seite mit ihr.
|
||||
|
||||
## Warum diese Befehle CLI-Befehle und nicht einfach SQL sind
|
||||
|
||||
Zwei Gründe:
|
||||
|
||||
1. **Atomarität** — die Erstellung einer Seite erfordert Einfügungen in zwei Tabellen (die Seite und ihre Textzeilen). Die CLI verpackt dies in eine Transaktion.
|
||||
2. **Eindeutigkeit des Slugs** — die CLI überprüft vor der Einfügung auf Kollisionen und gibt einen freundlicheren Fehler als eine Verletzung einer SQL-Einschränkung.
|
||||
|
||||
## Ohne das CMS-Modul
|
||||
|
||||
`-new-cms-page` und `-delete-cms-page` beenden mit einem nicht-nulligen Exit-Code und einer klaren Fehlermeldung, wenn das Modul `cms` nicht installiert ist. Fügen Sie es mit folgendem Befehl hinzu:
|
||||
```bash
|
||||
./nibiru -m cms
|
||||
./nibiru -mi local
|
||||
```
|
||||
(Siehe [Module](/core/modules/) für die Funktionalität von `./nibiru -m` und die Migrationsdateien, die das `cms`-Modul mitbringt.)
|
||||
|
||||
## Inhalte nach der Erstellung bearbeiten
|
||||
|
||||
Die Befehlszeilenschnittstelle (CLI) bearbeitet keinen Text – dies ist absichtlich der Web-Benutzeroberfläche des CMS-Moduls überlassen worden. Aus dem Produktionscode:
|
||||
```php
|
||||
// Read all text identifiers for a controller path + language
|
||||
$texts = \Nibiru\Module\Cms\Cms::init('about-us')
|
||||
->loadCmsTemplateTextsByControllerPath('about-us/page', $this->language);
|
||||
|
||||
foreach ($texts as $t) {
|
||||
\Nibiru\View::assign([
|
||||
$t['cms_template_texts_text_identifier']
|
||||
=> $t['cms_template_texts_text_content']
|
||||
]);
|
||||
}
|
||||
```
|
||||
Das Ergebnis: Jede `{$identifier}` im Template wird automatisch mit dem Inhalt der aktuellen Sprache aufgefüllt. Nicht-Entwickler verwalten den Text über die Admin-Oberfläche; Entwickler verwalten das Layout über das Template.
|
||||
104
docs/src/content/docs/de/cli/migrations.md
Normal file
104
docs/src/content/docs/de/cli/migrations.md
Normal file
@@ -0,0 +1,104 @@
|
||||
---
|
||||
title: "Migrationen"
|
||||
description: "Nummerierte SQL-Migrationen, die durch den Nibiru CLI getrieben werden."
|
||||
---
|
||||
|
||||
Migrationen sind flache SQL-Dateien im Verzeichnis `application/settings/config/database/`, die in numerischer Reihenfolge ausgeführt werden. Die Befehlszeilenschnittstelle verfolgt, welche Dateien ausgeführt wurden, und überspringt sie bei nachfolgenden Aufrufen.
|
||||
|
||||
## Dateinamenskonventionen
|
||||
```
|
||||
NNN-<slug>.sql
|
||||
```
|
||||
- `NNN`: Null aufgefüllte dreistellige Nummer für die Sortierreihenfolge.
|
||||
- `<slug>`: Bindestrich-getrennte Beschreibung (`add-account-email`, `create-acl-data`).
|
||||
|
||||
Beispiel-Aufschlüsselung:
|
||||
```
|
||||
001-acl.sql
|
||||
002-account.sql
|
||||
003-api_registry.sql
|
||||
004-timeanddate.sql
|
||||
005-user.sql
|
||||
006-user_to_account.sql
|
||||
007-timeanddate_to_account.sql
|
||||
008-user_to_acl.sql
|
||||
009-account_to_api_registry.sql
|
||||
010-timeanddate_to_user.sql
|
||||
011-acl-data.sql
|
||||
012-add-unique-key-user.sql
|
||||
013-add-account-email.sql
|
||||
```
|
||||
## Migrations ausführen
|
||||
```bash
|
||||
./nibiru -mi local # APPLICATION_ENV=development
|
||||
APPLICATION_ENV=staging ./nibiru -mi staging
|
||||
APPLICATION_ENV=production ./nibiru -mi production
|
||||
```
|
||||
Der `{env}`-Argument und die `APPLICATION_ENV` sollten übereinstimmen. Migrationen zielen auf die in `settings.<env>.ini` unter `[DATABASE]` konfigurierte Datenbank ab.
|
||||
|
||||
## Was der Runner macht
|
||||
|
||||
Für jede `*.sql`-Datei in numerischer Reihenfolge:
|
||||
|
||||
1. Sucht nach seinem Dateinamen in der Tabelle `_migrations`.
|
||||
2. Wenn nicht vorhanden, öffnet eine Transaktion (wenn der Treiber DDL-Transaktionen unterstützt), führt die Datei aus und fügt einen Datensatz bei Erfolg ein.
|
||||
3. Wenn eine Anweisung fehlschlägt, wird rückgängig gemacht und mit einem Nicht-null-Ausgang beendet.
|
||||
|
||||
Die `_migrations` Tabelle wird beim ersten Start automatisch erstellt.
|
||||
|
||||
## Idempotente SQL
|
||||
|
||||
Schreiben Sie immer Migrationen, die ohne Fehler erneut ausgeführt werden können, nach einem teilweisen Fehler:
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS api_registry (
|
||||
api_registry_id INT(11) NOT NULL AUTO_INCREMENT,
|
||||
api_registry_name VARCHAR(255) NOT NULL,
|
||||
PRIMARY KEY (api_registry_id),
|
||||
UNIQUE KEY api_registry_name_uk (api_registry_name)
|
||||
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4;
|
||||
```
|
||||
Für PostgreSQL:
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS api_registry (
|
||||
api_registry_id SERIAL PRIMARY KEY,
|
||||
api_registry_name TEXT NOT NULL UNIQUE
|
||||
);
|
||||
```
|
||||
Für ALTER-Anweisungen bevorzugen Sie Klauseln mit `IF NOT EXISTS` auf unterstützten Engines:
|
||||
```sql
|
||||
ALTER TABLE "user" ADD COLUMN IF NOT EXISTS user_email VARCHAR(255);
|
||||
```
|
||||
Falls Ihr Motor keine Unterstützung für `IF NOT EXISTS` bei der Änderung bietet, umschließen Sie die Anweisung in eine Guard-Abfrage, die nichts tut, wenn die Änderung bereits vorhanden ist.
|
||||
|
||||
## Befehle zum Zurücksetzen
|
||||
|
||||
::: caution
|
||||
Die Reset-Befehle leeren die Migrations-Audit-Tabelle – sie löschen Ihre Datenbanktabellen nicht. Nach einem Reset führt der nächste `-mi` *jede* Migration erneut aus. Stellen Sie sicher, dass Ihr SQL idempotent ist, bevor Sie zurücksetzen.
|
||||
:::
|
||||
```bash
|
||||
./nibiru -mi-reset local # forget all applied migrations
|
||||
./nibiru -mi-reset-file 005-user.sql local # forget a single file
|
||||
```
|
||||
Das Einzeldateiformular ist nützlich, wenn Sie einen Fehler in einer zuvor angewandten Migration behoben haben und nur diese Datei erneut ausführen möchten.
|
||||
|
||||
## Verzweigungsreinheit
|
||||
|
||||
Zwei Ingenieure, die an parallelen Zweigen arbeiten, können beide `015-…` hinzufügen und kollidieren. Konventionen, die helfen:
|
||||
|
||||
- **Zahlen in Pull-Request-Titeln vorbehalten**, bevor Sie die SQL schreiben.
|
||||
- **Verwenden Sie einen großen Abstand** für Hotfixes (z.B. behalten Sie `099`, `199`, `299` für Notfall-Picks vor).
|
||||
- **Fügen Sie lieber additive Migrationen** (neue Tabellen, neue Spalten) als zerstörende ones (Drops) hinzu. Diese führen sauberer zusammen.
|
||||
|
||||
## Zusammenführen
|
||||
|
||||
Für langfristige Projekte sollten alte Migrationen regelmäßig in eine einzelne Seed-Datei zusammengefasst werden, die das *aktuelle* Schema darstellt. Verschieben Sie die Originalien nach `archive/`, sodass der Überwachungsverlauf weiterhin besteht, und erstellen Sie eine neue `000-baseline-2026.sql`, die alles auf einmal erstellt. Aktualisieren Sie den Migrationsrunner mit einer manuellen `INSERT INTO _migrations`, um alte Dateien als angewendet zu markieren.
|
||||
|
||||
## Schema-first Modelle
|
||||
|
||||
Wenn `[GENERATOR] database = true` ist, werden die Modelle nach jeder Migration aus dem Live-Schema neu generiert. Also ist ein typischer Deploy-Workflow:
|
||||
```bash
|
||||
./nibiru -mi production
|
||||
# Generator regenerates models on the next request.
|
||||
./nibiru -cache-clear
|
||||
```
|
||||
Für zero-downtime Deploys: Deaktivieren Sie den Generator (`database = false`) und übertragen Sie die neu generierten Modelle zusammen mit den Migrationen, auf denen sie abhängen.
|
||||
80
docs/src/content/docs/de/cli/overview.md
Normal file
80
docs/src/content/docs/de/cli/overview.md
Normal file
@@ -0,0 +1,80 @@
|
||||
---
|
||||
title: "Die Nibiru CLI"
|
||||
description: "Jedes Flag, jeder Unterbefehl des `./nibiru` Binaries."
|
||||
---
|
||||
|
||||
Die `./nibiru`-Binärdatei ist ein kompiliertes Befehlszeilentool, das in jedem Nibiru-Projekt enthalten ist. Es erstellt Module, Controller und Plugins, führt Migrationen durch, verwaltet den Cache und (mit dem CMS-Modul) erstellt und löscht Seiten.
|
||||
```
|
||||
_ _ _ _ _ ______ _
|
||||
| \ | (_) | (_) | ____| | |
|
||||
| \| |_| |__ _ _ __ _ _ | |__ _ __ __ _ _ __ ___ _____ _____ _ _| | __
|
||||
| . ` | | '_ \| | '__| | | | | __| '__/ _` | '_ ` _ \ / _ \ \ /\ / / _ \| '__| |/ /
|
||||
| |\ | | |_) | | | | |_| | | | | | | (_| | | | | | | __/\ V V / (_) | | | <
|
||||
|_| \_|_|_.__/|_|_| \__,_| |_| |_| \__,_|_| |_| |_|\___| \_/\_/ \___/|_| |_|\_\
|
||||
```
|
||||
## Alle Flags
|
||||
|
||||
| Flag | Was es macht |
|
||||
|---|---|
|
||||
| `-m {name}` | Erstellt ein neues Modul mit dem Namen `{name}`. Fügen Sie `-g` hinzu, um Graylog-Protokollierungshooks zu verbinden. |
|
||||
| `-c {name}` | Erstellt einen neuen Controller `{name}` zusammen mit seiner Vorlage. |
|
||||
| `-p {name} -m {module}` | Erstellt ein neues Plugin `{name}` innerhalb von `{module}`. Fügen Sie `-g` für Graylog hinzu. |
|
||||
| `-cache-clear` | Löscht `application/view/templates_c/` und `application/view/cache/`. |
|
||||
| `-s` | Bootstrap-Framework-Ordner erstellen und Berechtigungen reparieren. Führen Sie einmal nach der Installation aus. |
|
||||
| `-mi {env}` | Führt Migrationen aus `application/settings/config/database/` für `local`, `staging` oder `production` durch. |
|
||||
| `-mi-reset {env}` | Löscht die Migrations-Audit-Tabelle für `{env}`. **Zerstörend.** |
|
||||
| `-mi-reset-file {file} {env}` | Vergisst, dass eine einzelne Migrationsdatei für `{env}` ausgeführt wurde. |
|
||||
| `-ws {URL} -wp {PORT}` | Verbindet sich mit einem WebSocket an `{URL}:{PORT}` (interaktive REPL). |
|
||||
| `-new-cms-page {name}` | (Nur CMS-Modul) Erstellt eine neue CMS-Seite, die einer vorhandenen Vorlage gebunden ist. |
|
||||
| `-delete-cms-page {name}` | (Nur CMS-Modul) Löscht eine CMS-Seite. |
|
||||
| `-h` | Zeigt den Hilfetext an. |
|
||||
| `-v` / `-version` | Gibt die Version des Binaries und der Framework-Version aus. |
|
||||
|
||||
## Tägliche Befehle, die Sie tatsächlich verwenden werden
|
||||
```bash
|
||||
# create a controller + view
|
||||
./nibiru -c products
|
||||
|
||||
# create a module with Graylog hooks
|
||||
./nibiru -m billing -g
|
||||
|
||||
# create a plugin inside that module
|
||||
./nibiru -p invoices -m billing
|
||||
|
||||
# run migrations
|
||||
./nibiru -mi local
|
||||
|
||||
# clear the Smarty cache after a deploy
|
||||
./nibiru -cache-clear
|
||||
|
||||
# show framework version
|
||||
./nibiru -v
|
||||
```
|
||||
## Umgebungen
|
||||
|
||||
Die meisten Befehle beachten `APPLICATION_ENV`:
|
||||
```bash
|
||||
APPLICATION_ENV=production ./nibiru -mi production
|
||||
APPLICATION_ENV=staging ./nibiru -mi staging
|
||||
```
|
||||
Der nachfolgende `{env}`-Argument für `-mi` wählt das Ziel der Migrationen aus; beide müssen übereinstimmen.
|
||||
|
||||
## Auf was die Befehlszeilenschnittstelle (CLI) aufbaut
|
||||
|
||||
Die ausführbare Datei ist eine kompilierte C++-Anwendung, die gegen die MySQL-, PostgreSQL (libpq)- und ODBC-Clients-Bibliotheken verlinkt. Die konditionale Kompilierung bedeutet, dass eine mit libpq gebaute Binärdatei auch für MySQL-einzige Bereitstellungen funktioniert – ein sanfter Abstieg anstatt einer festen Abhängigkeit.
|
||||
|
||||
Sie finden die ausführbare Datei im Projektstamm neben `index.php`. Sie ist direkt ausführbar (`chmod +x nibiru`, falls erforderlich).
|
||||
|
||||
## CI-Integration
|
||||
|
||||
Ein einfacher Schritt in GitHub Actions:
|
||||
```yaml
|
||||
- name: Run migrations
|
||||
run: |
|
||||
APPLICATION_ENV=production ./nibiru -mi production
|
||||
env:
|
||||
DB_HOST: ${{ secrets.DB_HOST }}
|
||||
DB_USER: ${{ secrets.DB_USER }}
|
||||
DB_PASS: ${{ secrets.DB_PASS }}
|
||||
```
|
||||
Die CLI beendet sich mit einem Nicht-null-Ausgabestatus, wenn eine Migration fehlschlägt, sodass CI SQL-Fehler erfasst.
|
||||
111
docs/src/content/docs/de/cli/scaffolding.md
Normal file
111
docs/src/content/docs/de/cli/scaffolding.md
Normal file
@@ -0,0 +1,111 @@
|
||||
---
|
||||
title: "Modul- und Controllergerüste"
|
||||
description: "Generieren Sie Controller, Module und Plugins mit der Befehlszeilenschnittstelle (CLI)."
|
||||
---
|
||||
|
||||
## Controller
|
||||
```bash
|
||||
./nibiru -c products
|
||||
```
|
||||
Erstellt zwei Dateien:
|
||||
```
|
||||
application/controller/productsController.php
|
||||
application/view/templates/products.tpl
|
||||
```
|
||||
Der Controller-Stub:
|
||||
```php
|
||||
<?php
|
||||
namespace Nibiru;
|
||||
use Nibiru\Adapter\Controller;
|
||||
|
||||
class productsController extends Controller
|
||||
{
|
||||
public function pageAction() {
|
||||
View::assign(['title' => 'Products']);
|
||||
}
|
||||
|
||||
public function navigationAction() {
|
||||
JsonNavigation::getInstance()->loadJsonNavigationArray();
|
||||
}
|
||||
}
|
||||
```
|
||||
Die Vorlagestub:
|
||||
```smarty
|
||||
{include 'shared/header.tpl'}
|
||||
<body>
|
||||
{include file="navigation.tpl"}
|
||||
<main class="container">
|
||||
<h1>{$title}</h1>
|
||||
</main>
|
||||
{include 'shared/footer.tpl'}
|
||||
</body>
|
||||
```
|
||||
Controller sind nur PHP-Dateien – keine JS- oder CSS-Skelettstruktur, sodass Sie die Verwaltung der Assets selbstständig steuern können.
|
||||
|
||||
## Module
|
||||
```bash
|
||||
./nibiru -m billing
|
||||
```
|
||||
Erstellt:
|
||||
```
|
||||
application/module/billing/
|
||||
├── billing.php
|
||||
├── interfaces/billing.php
|
||||
├── plugins/
|
||||
├── settings/billing.ini
|
||||
└── traits/
|
||||
```
|
||||
Die Hauptklasse implementiert `IModule` und macht einen Konstruktor verfügbar, der die Registrierungskonfiguration des Moduls lädt. Fügen Sie den Flag `-g` hinzu, um die Graylog-Observer-Wiring standardmäßig einzubinden:
|
||||
```bash
|
||||
./nibiru -m billing -g
|
||||
```
|
||||
Wenn `-g` gesetzt ist, importiert das Scafolding einen `Graylog`-Observer, fügt ihn im Konstruktor hinzu und gibt bei wichtigen Zustandsänderungen `notify()` aus – sodass jeder GELF-fähige Graylog-Server die Modulereignisse ohne zusätzliche Anpassungen aufnimmt.
|
||||
|
||||
## Plugins
|
||||
|
||||
Ein Plugin lebt innerhalb eines Moduls:
|
||||
```bash
|
||||
./nibiru -p invoices -m billing
|
||||
```
|
||||
Erstellt `application/module/billing/plugins/invoices.php`:
|
||||
```php
|
||||
<?php
|
||||
namespace Nibiru\Module\Billing\Plugin;
|
||||
use Nibiru\Module\Billing\Billing;
|
||||
|
||||
class Invoices extends Billing
|
||||
{
|
||||
public function listOpen(): array
|
||||
{
|
||||
return \Nibiru\Pdo::fetchAll(
|
||||
'SELECT * FROM invoices WHERE status = :s ORDER BY due_date',
|
||||
[':s' => 'open']
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
Plugins erben vom Modul und teilen daher dessen Registry, Einstellungen und Observer-Maschinerie.
|
||||
|
||||
## Bootstrap (`-s`)
|
||||
|
||||
`./nibiru -s` wird nach der Installation (oder nach dem Abrufen eines frischen Checkout) ausgeführt, um:
|
||||
|
||||
- Erstellen Sie `application/view/templates_c/` und `application/view/cache/`, falls diese fehlen.
|
||||
- Überprüfen und korrigieren Sie die Berechtigungen (schreibbar für den Webserver-Benutzer) dieser Ordner.
|
||||
- Stellen Sie sicher, dass die erforderlichen PHP-Erweiterungen geladen sind.
|
||||
- Überprüfen Sie, ob der Datenbanktreiber in Ihrer INI-Datei von diesem Binärbuild unterstützt wird.
|
||||
|
||||
Es ist sicher, mehrmals zu führen.
|
||||
|
||||
## Cache leeren (`-cache-clear`)
|
||||
|
||||
Löscht sowohl den Smarty-Kompilierungs-Cache als auch den gerenderten HTML-Cache:
|
||||
```bash
|
||||
./nibiru -cache-clear
|
||||
```
|
||||
Führen Sie nach einer Bereitstellung aus, wenn:
|
||||
- Sie `.tpl`-Dateien geändert haben,
|
||||
- Sie die `[ENGINE]`-Caching-Einstellungen geändert haben,
|
||||
- Sie Smarty-Plugins modifiziert haben.
|
||||
|
||||
Der Cache wird beim nächsten Anfrage erneut generiert.
|
||||
130
docs/src/content/docs/de/core/architecture.md
Normal file
130
docs/src/content/docs/de/core/architecture.md
Normal file
@@ -0,0 +1,130 @@
|
||||
---
|
||||
title: "Architektur (MMVC)"
|
||||
description: "Wie sich Module, Controller, Views, Modelle, der Registry und der Dispatcher gegenseitig beeinflussen."
|
||||
---
|
||||
|
||||
Nibiru ist **MMVC**: Modell — Ansicht — Steuerung — und ein zweiter **M** für **Modul**. Die ersten drei sind vertraut; das zweite M ist, was Nibiru seinen Geschmack gibt.
|
||||
|
||||
<figure>
|
||||
<img src="/img/architecture-lotus.png" alt="Querschnittsillustration der Nibiru-Laufzeit als Lotus, wobei jede Blattfläche als Systemkomponente beschriftet ist: Router, Controller, View, Model, Modul, Registry." />
|
||||
<figcaption>Die Nibiru-Laufzeit als Lotus.</figcaption>
|
||||
</figure>
|
||||
|
||||
## Das 30-Sekunden-Gedächtnismodell
|
||||
```
|
||||
┌──────────────┐
|
||||
│ Browser │
|
||||
└──────┬───────┘
|
||||
│ HTTP
|
||||
▼
|
||||
┌──────────────┐
|
||||
│ index.php │
|
||||
└──────┬───────┘
|
||||
│ require
|
||||
▼
|
||||
┌──────────────┐
|
||||
│ framework.php│ ◀── boots Config, Router, Engine, Smarty,
|
||||
└──────┬───────┘ all 28 form types, DB drivers, Auth.
|
||||
│
|
||||
▼
|
||||
┌──────────────┐
|
||||
│ Dispatcher │
|
||||
└──────┬───────┘
|
||||
│
|
||||
┌───────────────┼─────────────────┐
|
||||
▼ ▼ ▼
|
||||
┌────────┐ ┌──────────┐ ┌───────────┐
|
||||
│ Router │ │ Modules │ │ Auto │
|
||||
└────────┘ └──────────┘ │ loader │
|
||||
└───────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────┐
|
||||
│ applicationController.php │
|
||||
│ navigationAction() │
|
||||
│ <_action>Action() │
|
||||
│ pageAction() │
|
||||
└──────────────┬──────────────┘
|
||||
│ View::assign(...)
|
||||
▼
|
||||
┌──────────────┐
|
||||
│ Smarty │ templates/<ctrl>.tpl + shared/*
|
||||
└──────────────┘
|
||||
```
|
||||
## Die fünf Bürger
|
||||
|
||||
### 1. Controller (`application/controller/`)
|
||||
|
||||
Ein Controller ist eine Klasse, die von `Nibiru\Adapter\Controller` erbt. Der Dispatcher führt eine festgelegte Sequenz bei jedem Anfrageaufruf aus:
|
||||
|
||||
1. `navigationAction()` — Menüs/Brotkrumen-Daten auffüllen.
|
||||
2. `<_action>Action()` — nur, wenn `?_action=foo` gesetzt ist.
|
||||
3. `pageAction()` — endgültige Datenzuweisung zur Renderzeit.
|
||||
|
||||
Nachdem alle drei zurückgekehrt sind, überreicht `Display::display()` die zugewiesenen Variablen an Smarty.
|
||||
|
||||
### 2. Ansichten (`application/view/templates/`)
|
||||
|
||||
Smarty `.tpl`-Dateien. Der Dispatcher löst `<controller>.tpl` automatisch auf; verschachtelte Aktionen befinden sich unter `templates/<controller>/<action>.tpl`. Jede über `View::assign(['x' => ...])` übergebene Variable ist als `{$x}` verfügbar.
|
||||
|
||||
### 3. Modelle (`application/model/`)
|
||||
|
||||
Modelle werden **automatisch generiert** aus Ihrem Datenbankschema durch `Model::__construct(false)` – eine PHP-Klasse pro Tabelle. Sie erweitern `Nibiru\Adapter\<Driver>\Db` und bieten CRUD-Hilfsprogramme. Sie können einen generierten Modell bearbeiten und den Regenerator mit `[GENERATOR] database = false` deaktivieren.
|
||||
|
||||
### 4. Module (`application/module/<name>/`)
|
||||
|
||||
Ein Modul kapselt **seine eigenen** Merkmale, Plugins, Schnittstellen und Einstellungen. Der [Registry](/core/registry/) entdeckt automatisch jede `settings/*.ini` des Moduls und macht die geparste Konfiguration über `Registry::getInstance()->loadModuleConfigByName('users')` zugänglich. Module können `SplSubject` implementieren, um das **Beobachtermuster** zu verwenden, was anderen Teilen des Systems erlaubt, Beobachter anzuhängen und auf Zustandsänderungen zu reagieren.
|
||||
|
||||
### 5. Singleton-Klassen, die das Universum zusammenhalten
|
||||
|
||||
| Singleton | Aufgabe |
|
||||
|---|---|
|
||||
| `Config::getInstance()` | Liest `settings.<env>.ini` und fügt Modulkonfigurationen hinzu. |
|
||||
| `Router::getInstance()` | Parst die URL in Controller/Aktion/Parameter; erkennt SEO-URL-Formen. |
|
||||
| `Registry::getInstance()` | Modulentdeckung + Konfigurations-Caching. |
|
||||
| `Dispatcher::getInstance()` | Der Dirigent. `dispatch::run()` ist das Herzschlag Ihrer Anwendung. |
|
||||
| `View::getInstance()` | Verpackt Smarty. `View::assign()` ist die globale Vorlagenvariablen-Eingabebox. |
|
||||
|
||||
## Warum MMVC und nicht MVC?
|
||||
|
||||
Einfache MVC funktioniert, bis Ihre Controller beginnen, Logik zu teilen – Authentifizierungsprüfungen, Formularfabriken, Third-Party-API-Clients. Die üblichen Antworten sind *Dienste + DI-Container*, aber das ist viel Zeremonie für ein Framework zur schnellen Prototypenentwicklung.
|
||||
|
||||
Nibiru's answer: **Module**. Ein Modul *besitzt* eine Domäne (`users`, `cms`, `analytics`, `tpms-quotes`), indem es seine Dienste über Plugins verfügbar macht, die Controller direkt instanziieren können:
|
||||
```php
|
||||
// In a controller
|
||||
$user = new \Nibiru\Module\Users\Plugin\User();
|
||||
if (!$user->isAuthorized()) {
|
||||
View::forwardTo('/login');
|
||||
}
|
||||
```
|
||||
Das Modul besitzt seine Konfiguration, seine Datenbanktabellen, seine Vorlagen und seine Formulare – und kann als Einheit entfernt werden.
|
||||
|
||||
## Der Beobachterpattern im Einsatz
|
||||
|
||||
Einige Module implementieren `SplSubject`, sodass andere Code Teile auf Ereignisse reagieren können, ohne Kopplung. Aus der Präsentation macht das `analytics`-Modul auf `prod.maschinen-stockert.de` genau dies: jeder Controller kann einen Tracker (`Matomo Plugin`) mit `attach()` anhängen und das Analytics-Modul benachrichtigt (`notify()`) bei jedem Seitenaufruf, ohne dass der Controller weißt, welche Trackers existieren.
|
||||
```php
|
||||
class Analytics implements \SplSubject {
|
||||
private \SplObjectStorage $observers;
|
||||
public function __construct() { $this->observers = new \SplObjectStorage(); }
|
||||
public function attach(\SplObserver $o): void { $this->observers->attach($o); }
|
||||
public function detach(\SplObserver $o): void { $this->observers->detach($o); }
|
||||
public function notify(): void {
|
||||
foreach ($this->observers as $o) { $o->update($this); }
|
||||
}
|
||||
}
|
||||
```
|
||||
## Was absichtlich nicht im Framework enthalten ist
|
||||
|
||||
- **Kein DI-Container.** Singletons und Plugins erfüllen die Aufgabe.
|
||||
- **Kein ORM.** Modelle werden aus dem Schema generiert; Abfragen verwenden den `Db` Adapter oder raw SQL über den aktiven Treiber.
|
||||
- **Keine Template-Ererbung durch Twig/Blade-Tricks.** Smarty `{include}` ist die Kompositions-Einheit.
|
||||
- **Kein Event-Bus.** `SplSubject`/`SplObserver` sind erstklassig.
|
||||
- **Keine ersten-Klasse Hintergrundjobs.** Die CLI ist Ihr Scheduler — führen Sie ihn über cron oder systemd Timers aus.
|
||||
|
||||
Weniger zu lernen, mehr zu liefern.
|
||||
|
||||
## Wo Sie weitergehen sollten
|
||||
|
||||
- [Bootstrap & Dispatcher](/core/dispatcher/) — der Lebenszyklus im Code.
|
||||
- [Routing](/core/routing/) — URL-zu-Controller-Zuordnungsregeln.
|
||||
- [Module](/core/modules/) — erstelle dein erstes Modul.
|
||||
206
docs/src/content/docs/de/core/auth.md
Normal file
206
docs/src/content/docs/de/core/auth.md
Normal file
@@ -0,0 +1,206 @@
|
||||
---
|
||||
title: "Auth"
|
||||
description: "Sitzungs-basierte Authentifizierung, das vordefinierte Anmeldeformular und das Benutzer-Plugin-Muster aus der Produktion."
|
||||
---
|
||||
|
||||
Nibiru bietet einen auf Sitzungen basierenden Authentifizierungskern (`Nibiru\Auth`) und ein Modul `users`, das Ihnen eine funktionierende Anmeldeformular, eine Autorisierung überprüft und das Datenbankschema in drei Befehlen bereitstellt.
|
||||
|
||||
## Der Auth-Kernpunkte
|
||||
|
||||
`Auth::auth($login, $password)` ist der niedrigste Funktionsaufruf. Er:
|
||||
|
||||
1. Sucht den Benutzer nach `user_login`.
|
||||
2. Entschlüsselt das gespeicherte Passwort mit dem Salt aus `[SECURITY] password_hash`.
|
||||
3. Bei Übereinstimmung wird `$_SESSION` durch `['auth' => ['session_id' => …, 'user_id' => …, 'login' => …]]` ersetzt.
|
||||
```php
|
||||
$auth = new \Nibiru\Auth();
|
||||
if ($auth->auth($_POST['login'], $_POST['password'])) {
|
||||
View::forwardTo('/dashboard');
|
||||
} else {
|
||||
View::assign(['error' => 'Invalid credentials.']);
|
||||
}
|
||||
```
|
||||
:::caution
|
||||
Das Standard-Schema speichert Passwörter mit **AES_DECRYPT**, die durch den INI-Salt verschlüsselt sind — umkehrenbar. Ausreichend für das Prototyping, **nicht** für öffentliche Produktion. Ersetzen Sie es durch `password_hash()` / `password_verify()` für echte Anwendungen. Siehe unten unter *Hardening*.
|
||||
:::
|
||||
|
||||
## Das Modul Benutzer
|
||||
|
||||
Generieren Sie es einmal mit der Befehlszeilenschnittstelle (CLI):
|
||||
```bash
|
||||
./nibiru -m users
|
||||
```
|
||||
Führen Sie dann die entsprechenden Migrationen aus:
|
||||
```bash
|
||||
./nibiru -mi local
|
||||
```
|
||||
Sie haben jetzt:
|
||||
|
||||
- `application/module/users/` — der Modulordner.
|
||||
- `users` Tabelle — erstellt durch `005-user.sql`.
|
||||
- `User` Plugin — `Nibiru\Module\Users\Plugin\User` mit `isAuthorized()`, `loginForm()`, `currentUser()`.
|
||||
|
||||
## Ein vollständiger Anmeldeprozess
|
||||
|
||||
`application/controller/loginController.php`:
|
||||
```php
|
||||
<?php
|
||||
namespace Nibiru;
|
||||
use Nibiru\Adapter\Controller;
|
||||
use Nibiru\Module\Users\Plugin\User;
|
||||
|
||||
class loginController extends Controller
|
||||
{
|
||||
private User $user;
|
||||
|
||||
public function __construct() {
|
||||
parent::__construct();
|
||||
$this->user = new User();
|
||||
}
|
||||
|
||||
public function pageAction() {
|
||||
if ($this->user->isAuthorized()) {
|
||||
View::forwardTo('/');
|
||||
return;
|
||||
}
|
||||
View::assign([
|
||||
'title' => 'Sign in',
|
||||
'loginForm' => $this->user->loginForm(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function submitAction() {
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') return;
|
||||
$auth = new Auth();
|
||||
if ($auth->auth($_POST['login'] ?? '', $_POST['password'] ?? '')) {
|
||||
View::forwardTo('/');
|
||||
} else {
|
||||
View::assign(['error' => 'Invalid login.']);
|
||||
}
|
||||
}
|
||||
|
||||
public function logoutAction() {
|
||||
unset($_SESSION['auth']);
|
||||
session_regenerate_id(true);
|
||||
View::forwardTo('/login');
|
||||
}
|
||||
|
||||
public function navigationAction() {
|
||||
JsonNavigation::getInstance()->loadJsonNavigationArray();
|
||||
}
|
||||
}
|
||||
```
|
||||
`application/view/templates/login.tpl`:
|
||||
```smarty
|
||||
{include 'shared/header.tpl'}
|
||||
<body>
|
||||
{include file="navigation.tpl"}
|
||||
<main class="container">
|
||||
<h1>{$title}</h1>
|
||||
{if $error}<div class="alert alert-danger">{$error}</div>{/if}
|
||||
{$loginForm nofilter}
|
||||
</main>
|
||||
{include 'shared/footer.tpl'}
|
||||
</body>
|
||||
```
|
||||
`Form::action="/login/submit"` wird in `loginForm()` gesetzt, sodass das Formular an die richtige Aktion gepostet wird.
|
||||
|
||||
## Schutz von Controllern
|
||||
|
||||
Die einfache Überprüfung:
|
||||
```php
|
||||
public function pageAction() {
|
||||
if (!$this->user->isAuthorized()) {
|
||||
View::forwardTo('/login');
|
||||
return;
|
||||
}
|
||||
// ...
|
||||
}
|
||||
```
|
||||
Für rollebasierte Überprüfungen verwenden die Showcase-Anwendungen den `Acl`-Plugin aus dem gleichen Modul:
|
||||
```php
|
||||
use Nibiru\Module\Users\Plugin\Acl;
|
||||
|
||||
if (!Acl::can('edit', 'documents')) {
|
||||
View::forwardTo('/forbidden');
|
||||
return;
|
||||
}
|
||||
```
|
||||
Tabellen `acl`, `user_to_acl`, `acl-data` (Migrationen 001, 008, 011) bilden die Grundlage für Rollen und Berechtigungen.
|
||||
|
||||
## Härterung
|
||||
|
||||
Für die Produktion ersetzen Sie `Auth::auth()` durch eine verstärkte Version:
|
||||
```php
|
||||
namespace Nibiru;
|
||||
use Nibiru\Pdo;
|
||||
|
||||
class HardenedAuth
|
||||
{
|
||||
public function auth(string $login, string $password): bool {
|
||||
$row = Pdo::fetchRow(
|
||||
'SELECT user_id, user_pass FROM "user" WHERE user_login = :l AND user_account_active = 1',
|
||||
[':l' => $login]
|
||||
);
|
||||
if (!$row || !password_verify($password, $row['user_pass'])) {
|
||||
return false;
|
||||
}
|
||||
if (password_needs_rehash($row['user_pass'], PASSWORD_ARGON2ID)) {
|
||||
Pdo::update('user', [
|
||||
'user_pass' => password_hash($password, PASSWORD_ARGON2ID),
|
||||
], ['user_id' => $row['user_id']]);
|
||||
}
|
||||
session_regenerate_id(true);
|
||||
$_SESSION['auth'] = [
|
||||
'session_id' => session_id(),
|
||||
'user_id' => $row['user_id'],
|
||||
'login' => $login,
|
||||
];
|
||||
return true;
|
||||
}
|
||||
}
|
||||
```
|
||||
Migrieren Sie vorhandene Zeilen beim ersten Anmelden mit `password_needs_rehash`.
|
||||
|
||||
## CSRF
|
||||
|
||||
Nibiru generiert keine CSRF-Token für Sie. Fügen Sie diese selbst hinzu:
|
||||
```php
|
||||
public function pageAction() {
|
||||
if (!isset($_SESSION['csrf'])) {
|
||||
$_SESSION['csrf'] = bin2hex(random_bytes(16));
|
||||
}
|
||||
View::assign(['csrf' => $_SESSION['csrf']]);
|
||||
}
|
||||
|
||||
public function submitAction() {
|
||||
if (!hash_equals($_SESSION['csrf'] ?? '', $_POST['csrf'] ?? '')) {
|
||||
http_response_code(419);
|
||||
return;
|
||||
}
|
||||
// ...handle submission...
|
||||
}
|
||||
```
|
||||
Fügen Sie `<input type="hidden" name="csrf" value="{$csrf}">` in Ihr Formular ein.
|
||||
|
||||
## QR-Code-Anmeldung (TPMS-Muster)
|
||||
|
||||
Produktionsanwendungen umfassen eine QR-codes-basierte Magics-Link-Anmeldung, die kurzlebige Token ausgibt. Der Ablauf:
|
||||
|
||||
1. Der Benutzer scannen einen QR-Code → Die URL lautet `/login/token/<one-time-token>`.
|
||||
2. `tokenAction` validiert und erstellt eine Sitzung.
|
||||
|
||||
Das Framework hängt bereits von `bacon/bacon-qr-code` und `picqer/php-barcode-generator` über Composer ab, sodass Sie QR-Codes direkt rendern können:
|
||||
```php
|
||||
$writer = new \BaconQrCode\Writer(new \BaconQrCode\Renderer\ImageRenderer(
|
||||
new \BaconQrCode\Renderer\RendererStyle\RendererStyle(220),
|
||||
new \BaconQrCode\Renderer\Image\SvgImageBackEnd()
|
||||
));
|
||||
$svg = $writer->writeString('https://app.example.com/login/token/' . $token);
|
||||
View::assign(['qr' => $svg]);
|
||||
```
|
||||
## Häufige Fallen
|
||||
|
||||
- **`$_SESSION` wird nicht zusammengeführt, sondern ersetzt** durch die Standardmethode `Auth::auth()`. Alles, was Sie vor dem Login gespeichert hatten, geht verloren. Speichern und wiederherstellen Sie explizit, wenn dies erforderlich ist.
|
||||
- **Keine Rate Limiting**. Fügen Sie Fail2Ban oder einen middleware-artigen Beobachter hinzu.
|
||||
- **Sitzungs-Cookies benötigen Flags.** Setzen Sie `session.cookie_secure = 1`, `session.cookie_httponly = 1` und `session.cookie_samesite = "Lax"` in der php.ini für die Produktion.
|
||||
133
docs/src/content/docs/de/core/config.md
Normal file
133
docs/src/content/docs/de/core/config.md
Normal file
@@ -0,0 +1,133 @@
|
||||
---
|
||||
title: "Konfiguration und Einstellungen"
|
||||
description: "Wie Nibiru Konfigurationen aus umgebungsspezifischen INI-Dateien lädt."
|
||||
---
|
||||
|
||||
Die Konfigurationsschicht von Nibiru ist absichtlich einfach: Beim Start wird eine einzelne `settings.<env>.ini`-Datei geparst, sie wird über einen Singleton zur Verfügung gestellt, und es können Module ihre eigenen INIs durch den [Registry](/core/registry/) hinzufügen.
|
||||
|
||||
## Umgebungsauswahl
|
||||
|
||||
Die Umgebungsvariable `APPLICATION_ENV` wählt die zu ladende Datei aus:
|
||||
|
||||
| `APPLICATION_ENV` | Datei geladen |
|
||||
|---|---|
|
||||
| `development` (Standard) | `application/settings/config/settings.development.ini` |
|
||||
| `staging` | `application/settings/config/settings.staging.ini` |
|
||||
| `production` | `application/settings/config/settings.production.ini` |
|
||||
```bash
|
||||
export APPLICATION_ENV=production
|
||||
./nibiru -mi production
|
||||
```
|
||||
Wenn `APPLICATION_ENV` nicht gesetzt ist, verwendet Nibiru standardmäßig `development`.
|
||||
|
||||
## Abschnitte
|
||||
|
||||
Ein typisches `settings.<env>.ini`:
|
||||
```ini
|
||||
[ENGINE]
|
||||
templates = "/../../application/view/templates/"
|
||||
templates_c = "/../../application/view/templates_c/"
|
||||
cache = "/../../application/view/cache/"
|
||||
config_dir = "/../../application/view/configs/"
|
||||
caching = false
|
||||
debug = true
|
||||
error.controller = "error"
|
||||
|
||||
[AUTOLOADER]
|
||||
iface.pos[] = "users"
|
||||
iface.pos[] = "cms"
|
||||
class.pos[] = "users"
|
||||
class.pos[] = "cms"
|
||||
|
||||
[SETTINGS]
|
||||
page.url = "https://my-app.local"
|
||||
navigation = "/../../application/settings/config/navigation/main.json"
|
||||
modules.path = "/../../application/module/"
|
||||
entries.per.page = 25
|
||||
smarty.css[] = "/public/css/app.css"
|
||||
smarty.js[] = "/public/js/app.js"
|
||||
timezone = "Europe/Vienna"
|
||||
|
||||
[DATABASE]
|
||||
driver = "pdo"
|
||||
hostname = "localhost"
|
||||
port = 3306
|
||||
username = "nibiru"
|
||||
password = "secret"
|
||||
basename = "nibiru_dev"
|
||||
encoding = "utf8mb4"
|
||||
is.active = true
|
||||
|
||||
[SECURITY]
|
||||
password_hash = "change-me-at-once"
|
||||
|
||||
[GENERATOR]
|
||||
database = true
|
||||
database.overwrite = true
|
||||
|
||||
[EMAIL]
|
||||
host = "smtp.example.com"
|
||||
port = 587
|
||||
encryption = "tls"
|
||||
username = "noreply@example.com"
|
||||
password = "smtp-secret"
|
||||
from = "Nibiru <noreply@example.com>"
|
||||
|
||||
[NIBIRU_ROUTING]
|
||||
; optional regex routes — see /core/routing/
|
||||
|
||||
[NIBIRU_SECURITY]
|
||||
password_hash = "another-salt-for-AES_DECRYPT"
|
||||
```
|
||||
## Konfiguration lesen
|
||||
```php
|
||||
$cfg = \Nibiru\Config::getInstance()->getConfig();
|
||||
$cfg['DATABASE']['driver']; // 'pdo'
|
||||
$cfg['SETTINGS']['page.url']; // 'https://my-app.local'
|
||||
```
|
||||
Verwenden Sie für tief geschachtelte Konfigurationen die typisierten Konstanten aus der View-Schnittstelle:
|
||||
```php
|
||||
$cfg[\Nibiru\View::NIBIRU_SETTINGS]['smarty.css']; // ['/public/css/app.css']
|
||||
```
|
||||
## Modulkonfigurationen
|
||||
|
||||
Jeder Modul unter `application/module/<name>/settings/` kann seine eigenen INI-Dateien haben. Der Registrierungsservice erkennt sie automatisch und macht sie über verfügbar:
|
||||
```php
|
||||
$users = \Nibiru\Registry::getInstance()->loadModuleConfigByName('users');
|
||||
$users->session_lifetime; // from [USERS] section in users.ini
|
||||
```
|
||||
Der Registrierung wird `<module>.<env>.ini` vor `<module>.ini` bevorzugt, sodass Sie automatisch Überschreibungen pro Umgebung erhalten.
|
||||
|
||||
## Geheimnisse
|
||||
|
||||
INI-Dateien sind *einfache Textdateien*. Zwei sichere Optionen für die Produktion:
|
||||
|
||||
### A. Umgebungsüberlagerung
|
||||
|
||||
Behalten Sie `settings.production.ini` im Versionskontrollsystem mit Platzhaltern bei und fügen Sie reale Werte aus Umgebungsvariablen zur Laufzeit ein:
|
||||
```ini
|
||||
[DATABASE]
|
||||
password = "${DB_PASSWORD}"
|
||||
```
|
||||
Erweitern Sie sie dann in Ihrem Container/CI:
|
||||
```bash
|
||||
envsubst < settings.production.ini.tpl > settings.production.ini
|
||||
```
|
||||
### B. Konfiguration außerhalb des Baums
|
||||
|
||||
Behalten Sie `settings.production.ini` außerhalb des Repositories und erstellen Sie einen Symlink beim Bereitstellen.
|
||||
```bash
|
||||
ln -s /etc/nibiru/settings.production.ini \
|
||||
application/settings/config/settings.production.ini
|
||||
```
|
||||
Das Framework interessiert sich nicht dafür, wo die Datei liegt, solange `parse_ini_file` sie lesen kann.
|
||||
|
||||
## Neuladen-Semantik
|
||||
|
||||
Die Konfiguration wird beim Booten **einmal** in die statische Variable `Settings` gelesen. Änderungen am INI-Format erfordern einen Anfragezyklus, um wirksam zu werden (oder für langlaufende Skripte einen expliziten Aufruf von `Settings::setConfig(\Nibiru\Config::getEnv())`).
|
||||
|
||||
## Häufige Fallen
|
||||
|
||||
- **Pfad mit `/../../`.** Die INI-Pfade des Frameworks sind *relativ zum Verzeichnis des Frameworks*. Sie müssen mit `/../../` beginnen, um in Ihr Projektverzeichnis zu wechseln. Ja, das sieht seltsam aus; ja, es funktioniert.
|
||||
- **Boolesche Analyse.** `parse_ini_file` ist toleranter — `true`, `on`, `1` werden alle zu `1`; `false`, `off`, `0`, `""` werden zu `0`. Verwenden Sie doppelte Anführungszeichen, wenn Sie wirklich einen Literalen `"true"` benötigen.
|
||||
- **Array-Werte.** Verwenden Sie die `[]`-Syntax (`smarty.css[] = …`) — Nibiru setzt darauf, dass diese Array sind.
|
||||
151
docs/src/content/docs/de/core/controllers.md
Normal file
151
docs/src/content/docs/de/core/controllers.md
Normal file
@@ -0,0 +1,151 @@
|
||||
---
|
||||
title: "Controller"
|
||||
description: "Schreiben von Nibiru-Controllern – der Aktion Lebenszyklus, View::assign und Muster aus Produktionscode."
|
||||
---
|
||||
|
||||
Ein Nibiru-Controller ist eine Klasse, die von `Nibiru\Adapter\Controller` erbt, sich in `application/controller/<name>Controller.php` befindet und automatisch vom [Dispatcher](/core/dispatcher/) geladen wird, wenn eine URL wie `/<name>/...` ankommt.
|
||||
|
||||
## Anatomie eines Controllers
|
||||
```php
|
||||
<?php
|
||||
namespace Nibiru;
|
||||
use Nibiru\Adapter\Controller;
|
||||
|
||||
class productsController extends Controller
|
||||
{
|
||||
public function pageAction() {
|
||||
View::assign([
|
||||
'title' => 'Products',
|
||||
'products' => $this->loadProducts(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function navigationAction() {
|
||||
JsonNavigation::getInstance()->loadJsonNavigationArray();
|
||||
}
|
||||
|
||||
public function detailAction() {
|
||||
$id = (int) ($_REQUEST['id'] ?? 0);
|
||||
View::assign(['product' => $this->loadProduct($id)]);
|
||||
}
|
||||
|
||||
private function loadProducts(): array { /* ... */ return []; }
|
||||
private function loadProduct(int $id): array { /* ... */ return []; }
|
||||
}
|
||||
```
|
||||
## Der Lebenszyklus der Aktion
|
||||
|
||||
Wenn der Dispatcher einen Controller aufruft, ruft er die Methoden in dieser festgelegten Reihenfolge auf:
|
||||
|
||||
1. **`navigationAction()`** — füllen Sie globale Menüs, Breadcrumbs und rollebasierte Navigation.
|
||||
2. **`<verb>Action()`** — nur wenn `?_action=<verb>` gesetzt ist oder die URL einen zweiten Abschnitt hat, der eine Aktion benennt.
|
||||
3. **`pageAction()`** — letzter Aufruf vor dem Rendern.
|
||||
|
||||
Sowohl `navigationAction()` als auch `pageAction()` werden **immer aufgerufen**, selbst für unbekannte Aktionen. Dies ist praktisch (Sie müssen nie überprüfen), kann aber überraschen, wenn Sie annehmen, dass Aktionen exklusiv sind.
|
||||
|
||||
## Kommunikation mit der Ansicht
|
||||
|
||||
`View::assign(['key' => $value, ...])` ist die Art und Weise, wie Daten an Templates gelangen. Es ist statisch und kann so oft aufgerufen werden, wie Sie möchten – später Aufrufe überschreiben frühere Aufrufe.
|
||||
```php
|
||||
View::assign(['title' => 'Products']);
|
||||
View::assign(['products' => $list]);
|
||||
|
||||
// In templates/products.tpl:
|
||||
// {$title} → "Products"
|
||||
// {$products} → the array
|
||||
```
|
||||
Hilfsfunktionen aus dem Basiscontroller:
|
||||
```php
|
||||
$this->getRequest('id', false); // $_REQUEST['id'] ?? false
|
||||
$this->getPost('email', ''); // $_POST['email'] ?? ''
|
||||
$this->getGet('page', 1); // $_GET['page'] ?? 1
|
||||
$this->getServer('REQUEST_URI'); // $_SERVER['REQUEST_URI']
|
||||
$this->getFiles('upload'); // $_FILES['upload']
|
||||
$this->getSession('auth'); // $_SESSION['auth']
|
||||
```
|
||||
Diese bestehen, weil `Controller` `final`-freundlich ist: Sie können sie in Tests durch eine untergeordnete Klasse ersetzen.
|
||||
|
||||
## Weiterleitung
|
||||
|
||||
Um innerhalb einer Aktion umzuleiten:
|
||||
```php
|
||||
View::forwardTo('/login'); // 302 to the URL, exits
|
||||
View::forwardToJsonHeader(); // sets Content-Type: application/json
|
||||
```
|
||||
`forwardToJsonHeader()` ist das kanonische Muster für JSON-Endpunkte – setzen Sie den Header, weisen Sie `data` zu und geben Sie zurück. Der Anzeigebereich kümmert sich danach um die Reste.
|
||||
|
||||
## Mehrere Aktionen pro Controller
|
||||
|
||||
Nibiru freut sich, eine beliebige Anzahl von Aktionen pro Controller zu hosten. Der TPMS-Controller `erpController` aus der Produktion hat `pageAction`, `navigationAction`, sowie `syncAction`, `statusAction`, `dryRunAction`, `cancelAction` usw. — jeweils über `?_action=sync` oder `/erp/sync` aufgerufen.
|
||||
```php
|
||||
// /erp/sync → $_REQUEST['_action'] = 'sync'
|
||||
public function syncAction(): void
|
||||
{
|
||||
View::forwardToJsonHeader();
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
View::assign(['data' => ['success' => false, 'error' => 'POST method required']]);
|
||||
return;
|
||||
}
|
||||
$result = AlphaplanSyncService::getInstance()->syncAbDocuments();
|
||||
View::assign(['data' => $result]);
|
||||
}
|
||||
```
|
||||
## Arbeiten mit Modulen
|
||||
|
||||
Controller sind dünn. Die meisten Logiken sollten in Modulen und deren Plugins leben:
|
||||
```php
|
||||
namespace Nibiru;
|
||||
use Nibiru\Adapter\Controller;
|
||||
use Nibiru\Module\Users\Plugin\User;
|
||||
|
||||
class loginController extends Controller
|
||||
{
|
||||
private User $user;
|
||||
|
||||
public function __construct() {
|
||||
parent::__construct();
|
||||
$this->user = new User();
|
||||
}
|
||||
|
||||
public function authAction() {
|
||||
if (!$this->user->isAuthorized()) {
|
||||
View::assign(['loginForm' => $this->user->loginForm()]);
|
||||
} else {
|
||||
View::forwardTo('/index');
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
Dieser *Delegierungs*-Muster ist konsistent in den Showcase-Anwendungen: Controller orchestrieren, Module führen die Arbeit aus.
|
||||
|
||||
## Mehrsprachige / CMS-gesteuerter Inhalt
|
||||
|
||||
Ein Muster von `prod.maschinen-stockert.de` — Laden Sie alle auf der Seite befindlichen Texte aus einer CMS-Tabelle, die nach dem Controller-Pfad indiziert ist:
|
||||
```php
|
||||
public function pageAction() {
|
||||
$controllerPath = $this->getController()
|
||||
. '/' . $this->getRequest('_action', 'page');
|
||||
|
||||
$texts = Cms::init($this->getController())
|
||||
->loadCmsTemplateTextsByControllerPath($controllerPath, $this->language);
|
||||
|
||||
foreach ($texts as $t) {
|
||||
View::assign([
|
||||
$t['cms_template_texts_text_identifier'] =>
|
||||
$t['cms_template_texts_text_content']
|
||||
]);
|
||||
}
|
||||
}
|
||||
```
|
||||
Ergebnis: Nicht-Entwickler können den Text ändern, ohne den Code zu berühren. Das CMS-Modul besitzt die Tabelle und die Editor-Benutzeroberfläche; der Controller lädt einfach nur Zeichenfolgen.
|
||||
|
||||
## Controller generieren
|
||||
|
||||
Die CLI erstellt einen Controller und seine Vorlage in einem Schritt:
|
||||
```bash
|
||||
./nibiru -c products
|
||||
```
|
||||
→ `application/controller/productsController.php`
|
||||
→ `application/view/templates/products.tpl`
|
||||
|
||||
Beide sind mit dem kanonischen Gerüst bevölligt. Sie sind bereit zu schreiben.
|
||||
153
docs/src/content/docs/de/core/database.md
Normal file
153
docs/src/content/docs/de/core/database.md
Normal file
@@ -0,0 +1,153 @@
|
||||
---
|
||||
title: "Datenbank & Migrationen"
|
||||
description: "Multitreibwerkzeug-Datenbankadapter, schemagesteuerter Modellgenerierung und nummerierte SQL-Migrationen."
|
||||
---
|
||||
|
||||
Nibiru bietet fünf Datenbanktreiber hinter einem einheitlichen `Db` Adapter an und einen nummerierten SQL-Migration Runner, der durch die [CLI](/cli/migrations/) getrieben wird.
|
||||
|
||||
## Treiberauswahl
|
||||
|
||||
Legen Sie den aktiven Treiber in `[DATABASE] driver` Ihrer INI-Datei fest:
|
||||
|
||||
| Treiber | Backend | Anmerkungen |
|
||||
|---|---|---|
|
||||
| `mysql` | Native MySQL/MariaDB (`mysqli`) | Schnellste, kein PDO-Overhead. |
|
||||
| `pdo` | PDO MySQL | Empfohlene Standardeinstellung. Vorbereitete Anweisungen, Treiber werden weit verbreitet verwendet. |
|
||||
| `postgres` | PostgreSQL über **ODBC** | Wenn Ihr Server nur ODBC zur Verfügung hat. |
|
||||
| `psql` | PostgreSQL über libpq (`pg_*`) | Native PostgreSQL. |
|
||||
| `postgresql` | Ein Wrapper, der den besten PostgreSQL-Transport zur Laufzeit auswählt. | |
|
||||
```ini
|
||||
[DATABASE]
|
||||
driver = "pdo"
|
||||
hostname = "localhost"
|
||||
port = 3306
|
||||
username = "nibiru"
|
||||
password = "secret"
|
||||
basename = "nibiru_dev"
|
||||
encoding = "utf8mb4"
|
||||
is.active = true
|
||||
```
|
||||
Der Dispatcher initialisiert den Treiber beim Booten. Das Wechseln der Treiber erfordert nur eine Änderung der Konfiguration und einen Neustart.
|
||||
|
||||
## Der Db Adapter
|
||||
|
||||
Jedes generierte Modell erweitert einen treiberspezifischen `Db`-Adapter (`Adapter\MySQL\Db`, `Adapter\PostgreSQL\Db`, `Adapter\Odbc\Db`). Alle Adapter teilen die gleiche Oberfläche:
|
||||
```php
|
||||
use Nibiru\Pdo;
|
||||
|
||||
// Read a single row by primary-key columns
|
||||
$row = Pdo::fetchRowInArrayById('users', ['user_id' => 42]);
|
||||
|
||||
// Run a parameterised query
|
||||
$rows = Pdo::fetchAll(
|
||||
'SELECT * FROM products WHERE category = :cat',
|
||||
[':cat' => 'gold-plating']
|
||||
);
|
||||
|
||||
// Insert
|
||||
Pdo::insert('products', [
|
||||
'name' => 'Marduk Gold Plating',
|
||||
'price' => 99.0,
|
||||
]);
|
||||
|
||||
// Update
|
||||
Pdo::update('products',
|
||||
['price' => 89.0],
|
||||
['id' => 1]);
|
||||
|
||||
// Delete
|
||||
Pdo::delete('products', ['id' => 1]);
|
||||
|
||||
// Last insert id
|
||||
Pdo::lastInsertId();
|
||||
```
|
||||
Treiber-spezifische Hilfsmethoden existieren direkt in den Adapter-Klassen selbst (`\Nibiru\Mysql::query()`, `\Nibiru\Postgresql::pgQuery()`, …), wenn Sie unverarbeitten Zugriff benötigen.
|
||||
|
||||
## Schema-first Modelle
|
||||
|
||||
Wenn `[GENERATOR] database = true` ist, regeneriert der Dispatcher bei jedem Anfrage eine PHP-Klasse pro Tabelle. Siehe [Modelle](/core/models/).
|
||||
|
||||
Im Produktionsumfeld:
|
||||
```ini
|
||||
[GENERATOR]
|
||||
database = false
|
||||
database.overwrite = false
|
||||
```
|
||||
Erneut generieren Sie nur beim Migrieren des Schemas.
|
||||
|
||||
## Migrationen
|
||||
|
||||
Migrationen sind **reine SQL-Dateien** im Verzeichnis `application/settings/config/database/`, nummeriert für die Ausführungsreihenfolge:
|
||||
```
|
||||
application/settings/config/database/
|
||||
├── 001-acl.sql
|
||||
├── 002-account.sql
|
||||
├── 003-api_registry.sql
|
||||
├── 004-timeanddate.sql
|
||||
├── 005-user.sql
|
||||
├── 006-user_to_account.sql
|
||||
├── 011-acl-data.sql
|
||||
├── 012-add-unique-key-user.sql
|
||||
└── 013-add-account-email.sql
|
||||
```
|
||||
Die Befehlszeilenschnittstelle wendet sie an mit:
|
||||
```bash
|
||||
./nibiru -mi local
|
||||
APPLICATION_ENV=staging ./nibiru -mi staging
|
||||
APPLICATION_ENV=production ./nibiru -mi production
|
||||
```
|
||||
Der Runner erstellt eine `_migrations`-Tabelle (pro-Treibername) und überspringt bereits angewendete Dateien. Jede Datei wird als einzelner Batch ausgeführt; wenn ein Anweisungsfehler währenddessen auftritt, beheben Sie den SQL-Fehler und führen erneut aus.
|
||||
|
||||
### Dateinamenskonventionen
|
||||
|
||||
- Drei-stelliger vorangestellter Nullen-padder numerischer Präfix: `NNN-<slug>.sql`.
|
||||
- Slugs beschreiben die Änderung (`-add-account-email`, `-drop-wrong-constraints`).
|
||||
- Eine logische Änderung pro Datei. Verwenden Sie keine Squash-Ausdrücke.
|
||||
- Für Daten-seeds verwenden Sie den `-data.sql` Suffix (`011-acl-data.sql`).
|
||||
|
||||
### Idempotenz
|
||||
|
||||
Verwenden Sie `IF NOT EXISTS` und `IF EXISTS`, damit Dateien sicher erneut ausgeführt werden können:
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS api_registry (
|
||||
api_registry_id INT(11) NOT NULL AUTO_INCREMENT,
|
||||
api_registry_name VARCHAR(255) NOT NULL,
|
||||
api_registry_token VARCHAR(64) NOT NULL,
|
||||
PRIMARY KEY (api_registry_id),
|
||||
UNIQUE KEY api_registry_token_uk (api_registry_token)
|
||||
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4;
|
||||
``````sql
|
||||
ALTER TABLE user
|
||||
ADD UNIQUE KEY user_login_uk (user_login)
|
||||
/* skip if exists; on MySQL 8 you can wrap with IF NOT EXISTS */;
|
||||
```
|
||||
### Zurücksetzungsbefehle (mit Vorsicht verwenden)
|
||||
```bash
|
||||
./nibiru -mi-reset local # forget all applied migrations
|
||||
./nibiru -mi-reset-file 005-user.sql local # forget a single file's run record
|
||||
```
|
||||
Diese Befehle entfernen keine Tabellen – nur die `_migrations` Überwachungstabelle. Kombinieren Sie dies mit einer manuellen `DROP`, wenn Sie wirklich eine saubere Sache haben möchten.
|
||||
|
||||
## PostgreSQL-Spezifika
|
||||
|
||||
Wenn `driver = "psql"` oder `"postgresql"`:
|
||||
|
||||
- Verwenden Sie `SERIAL` / `BIGSERIAL` anstelle von `AUTO_INCREMENT`.
|
||||
- Tabellen in `information_schema.tables` anstelle von `SHOW TABLES`.
|
||||
- Anführungszeichen: Bezeichner sind doppelt angegeben (`"user"` ist die korrekte Form, da `user` ein reserviertes Wort ist).
|
||||
- Das bedingte-Kompilierungsflag in der CLI bedeutet, dass PostgreSQL-Builds sich elegant degradiert, wenn `libpq` nicht zur Kompilierzeit vorhanden ist — überprüfen Sie `./nibiru -v`, um die Unterstützung zu bestätigen.
|
||||
|
||||
## Verbindungs-Pooling
|
||||
|
||||
Es gibt keine eingebaute Pool-Funktion. Für hochverkehrte Anwendungen verwenden Sie:
|
||||
|
||||
- **MariaDB / MySQL:** ProxySQL oder HAProxy vor der Datenbank.
|
||||
- **PostgreSQL:** PgBouncer im Transaktionspooling-Modus.
|
||||
|
||||
Nibiru öffnet eine Verbindung pro Anfrage, daher reicht in der Regel ein Pooler aus.
|
||||
|
||||
## Häufige Fallen
|
||||
|
||||
- **`is.active = false`** deaktiviert die Verbindungen stumm — überprüfen Sie dies, wenn Abfragen `null` zurückgeben.
|
||||
- **Generator im Produktionsmodus aktiviert.** Mit `[GENERATOR] database = true` wird bei jedem Anfrage die Modelldateien neu geschrieben. Deaktivieren Sie es nach Bereitstellungen.
|
||||
- **Gemischte Treiber.** Die Auswahl von `mysql` beim Ausführen von PostgreSQL führt zu einem erfolgreichen `Pdo::fetchAll`, das `[]` zurückgibt — die Verbindung schlägt stumm fehl. Überprüfen Sie immer mit `SELECT 1` in einer Rauchtest.
|
||||
108
docs/src/content/docs/de/core/dispatcher.md
Normal file
108
docs/src/content/docs/de/core/dispatcher.md
Normal file
@@ -0,0 +1,108 @@
|
||||
---
|
||||
title: "Bootstrap & Dispatcher"
|
||||
description: "Wie ein Nibiru-Request von index.php durch den Dispatcher zu Ihrem Controller fließt."
|
||||
---
|
||||
|
||||
Eine Anfrage an Nibiru durchläuft drei Dateien: `index.php`, `core/framework.php` und `core/c/dispatcher.php`. In dieser Reihenfolge zu lesen, verrät Ihnen alles.
|
||||
|
||||
## index.php
|
||||
```php
|
||||
<?php
|
||||
require_once 'core/framework.php';
|
||||
```
|
||||
Das ist die gesamte Datei. `index.php` existiert nur, um dem Webserver ein Ziel zu geben.
|
||||
|
||||
## core/framework.php
|
||||
|
||||
`framework.php` erfordert die Klassen des Frameworks in Abhängigkeitsreihenfolge – Einstellungen, Registrierung, Router, Engine, Autoloader, alle Datenbanktreiber, alle 28 Formulartypen, Ansicht, Controller, Module, Authentifizierung, Debugging, Anzeige – und endet mit:
|
||||
```php
|
||||
Nibiru\Dispatcher::getInstance()->run();
|
||||
```
|
||||
Dieser einzelne Aufruf ist das Herzschlag Ihrer Anwendung.
|
||||
|
||||
## Dispatcher::run()
|
||||
|
||||
Der vereinfachte Ablauf:
|
||||
```php
|
||||
public function run() {
|
||||
date_default_timezone_set(Config::getInstance()->getConfig()
|
||||
[View::NIBIRU_SETTINGS]['timezone']);
|
||||
|
||||
if (Config::getInstance()->getConfig()
|
||||
[self::CONFIG_GENERATOR_SECTION][self::GENERATOR_DATABASE]) {
|
||||
new Model(false); // 1. (re)generate models from schema
|
||||
}
|
||||
|
||||
Router::getInstance()->route(); // 2. parse the URL
|
||||
Auto::loader()->loadModelFiles(); // 3. load model files
|
||||
Auto::loader()->loadModules(); // 4. load module classes
|
||||
|
||||
$tpl = Router::getInstance()->tplName();
|
||||
$controllerFile = __DIR__ . "/../../application/controller/{$tpl}Controller.php";
|
||||
|
||||
if (is_file($controllerFile)) { // 5. controller file exists
|
||||
require_once $controllerFile;
|
||||
$class = "Nibiru\\{$tpl}Controller";
|
||||
$controller = new $class();
|
||||
|
||||
if (array_key_exists('_action', $_REQUEST)) {
|
||||
$action = $_REQUEST['_action'] . 'Action';
|
||||
$controller->navigationAction();
|
||||
if (method_exists($controller, $action)) {
|
||||
$controller->$action(); // 6. optional named action
|
||||
}
|
||||
$controller->pageAction();
|
||||
} else {
|
||||
$controller->navigationAction();
|
||||
$controller->pageAction();
|
||||
}
|
||||
|
||||
Display::getInstance()->display(); // 7. render Smarty
|
||||
} else {
|
||||
// 8. soft 404 — render the configured error controller
|
||||
}
|
||||
}
|
||||
```
|
||||
## Die Aktionenfolge
|
||||
|
||||
Jede Anfrage durchläuft die gleichen drei Schritte, wenn `?_action=foo` gesetzt ist:
|
||||
|
||||
1. `navigationAction()` — Menüs und Breadcrumbs befüllen.
|
||||
2. `fooAction()` — die benannte Aktion ausführen.
|
||||
3. `pageAction()` — letzte Gelegenheit, Template-Daten zuzuweisen.
|
||||
|
||||
Ohne `_action` laufen nur die Schritte 1 und 3.
|
||||
|
||||
Dies bedeutet, dass **stateless Renderzeitlogik in `pageAction()`** und **Navigationsdaten in `navigationAction()`** gehören, auch wenn es sich anfühlt, als wäre es eine Verdoppelung der Arbeit. Zwei Controller im gleichen Projekt haben beide eine `navigationAction()`. Das ist korrekt.
|
||||
|
||||
## Weiche 404
|
||||
|
||||
Wenn die abgeglichene Controller-Datei nicht existiert, rendert Nibiru einen *weichen 404* — es gibt eine 200 OK-Antwort und rendert den Controller, der in `[ENGINE] error.controller` (Standard: `error`) angegeben ist. Dies ist absichtlich: Es ermöglicht Ihnen, schöne Fehlerseiten ohne serverseitige Konfiguration bereitzustellen.
|
||||
|
||||
Wenn Sie einen echten `404 Not Found` HTTP-Code benötigen, setzen Sie ihn in Ihrem Fehlercontroller:
|
||||
```php
|
||||
public function pageAction() {
|
||||
http_response_code(404);
|
||||
View::assign(['title' => 'Lost in the void']);
|
||||
}
|
||||
```
|
||||
## Auto::loader()
|
||||
|
||||
Zwei Autoloader laufen vor dem Aufbau des Controllers:
|
||||
|
||||
- **`loadModelFiles()`** durchsucht `application/model/` und inkludiert jede `.php`-Datei dort. Generierte Modelle sind flache Dateien, keine benannten Pakete, daher ist dies eine einfache Schleife mit `require_once`.
|
||||
- **`loadModules()`** durchläuft `application/module/<name>/` und lädt die Hauptklasse jedes Moduls sowie seine Trait-, Plugin- und Schnittstellen-Dateien. Der Registry werden gleichzeitig die Einstellungen des Moduls in der INI-Datei indiziert.
|
||||
|
||||
Beide verwenden die konfigurierten Pfade in `[EINSTELLUNGEN] modules.path` usw., sodass Sie sie je nach Umgebung überschreiben können.
|
||||
|
||||
## SEO-URL-Verwaltung
|
||||
|
||||
Vor der Controller-Auflösung führt `Router::route()` die Methode `handleSeoUrls()` aus, die URLs des Formats `/controller/<slug>/<id>` (wobei das zweite Segment *keine* bekannte Aktion ist und das dritte numerisch) erkennt. Diese werden intern in `/controller/detail/` umgeschrieben, wobei `$_REQUEST['id']` und `$_REQUEST['slug']` befüllt werden. Weitere Informationen finden Sie unter [Routing](/core/routing/).
|
||||
|
||||
## Wann der Dispatcher überschrieben werden sollte
|
||||
|
||||
Fast niemals. Die sauberen Erweiterungspunkte sind:
|
||||
|
||||
- **Benutzerdefinierter Fehlercontroller** — setzen Sie `[ENGINE] error.controller`.
|
||||
- **Pre-controller Hooks** — laden Sie ein Modul, das einen Beobachter registriert, und fügen Sie ihn innerhalb von `navigationAction()` hinzu.
|
||||
- **Cron-artige Eintragung** — führen Sie das Framework ohne Benutzeroberfläche über die `nibiru` CLI aus, anstatt über `index.php`.
|
||||
219
docs/src/content/docs/de/core/forms.md
Normal file
219
docs/src/content/docs/de/core/forms.md
Normal file
@@ -0,0 +1,219 @@
|
||||
---
|
||||
title: "Formulare"
|
||||
description: "Bauen Sie Formulare flüssig mit der statischen Fabrik von Nibiru."
|
||||
---
|
||||
|
||||
Formulare in Nibiru werden **flüssig** durch Aufruf statischer Methoden auf `\Nibiru\Factory\Form` erstellt. Jeder Aufruf fügt einen HTML-Teil einem internen statischen Puffer hinzu; ein abschließender `Form::addForm()` verpackt den Puffer in ein `<form>`-Element und gibt die gerenderte HTML-Zeichenfolge zurück.
|
||||
|
||||
## Importieren Sie die Fabrik
|
||||
```php
|
||||
use Nibiru\Factory\Form;
|
||||
```
|
||||
Das ist der einzige `use`, den Sie benötigen. Jeder Eingabetyp ist eine statische Methode dieser Klasse.
|
||||
|
||||
## Der vollständige Methodenkatalog
|
||||
|
||||
Es gibt drei Arten der Methode, nach historischem Benennung.
|
||||
|
||||
### `addInputType*` — für tatsächliche `<input>`-Elemente
|
||||
```
|
||||
addInputTypeText addInputTypePassword addInputTypeEmail
|
||||
addInputTypeDate addInputTypeDatetime addInputTypeColor
|
||||
addInputTypeRadio addInputTypeCheckbox addInputTypeSwitch
|
||||
addInputTypeSubmit addInputTypeTextarea
|
||||
```
|
||||
### `addType*` — für nicht-`<input>` Formularelemente
|
||||
```
|
||||
addTypeFileUpload addTypeHidden addTypeImageSubmit
|
||||
addTypeNumber addTypeRange addTypeReset
|
||||
addTypeSearch addTypeTelefon addTypeUrl
|
||||
addTypeButton addTypeLabel
|
||||
```
|
||||
### `addSelect` / `addSelectOption` — für `<select>` + `<option>`
|
||||
```
|
||||
addSelect addSelectOption
|
||||
```
|
||||
### Layout-Hilfsprogramme
|
||||
```
|
||||
addOpenDiv addCloseDiv addOpenAny addCloseAny
|
||||
addOpenSpan addCloseSpan
|
||||
```
|
||||
### Lebenszyklus
|
||||
```
|
||||
create // reset the static buffer; call before building a new form
|
||||
addForm // wrap the buffer in <form>...</form> and return as a string
|
||||
```
|
||||
:::caution[Die Namensinconsistenz ist absichtlich, zumindest teilweise]
|
||||
`addInputTypePassword` vs `addTypePassword`, `addInputTypeFileupload` vs `addTypeFileUpload` — das Präfix entspricht, ob das Element ein `<input type="…">` (Input-Prefix) oder ein anderer Tag (Type-Prefix) ist. Es ist unangenehm, aber stabil; die CLI-`./nibiru -c`-Scaffolds verwenden dasselbe Muster, sodass die Muskelspeicherung funktioniert.
|
||||
:::
|
||||
|
||||
## Ein Formular erstellen
|
||||
```php
|
||||
use Nibiru\Factory\Form;
|
||||
|
||||
Form::create(); // reset the static buffer
|
||||
|
||||
Form::addOpenDiv(['class' => 'form-group']);
|
||||
Form::addTypeLabel(['for' => 'login', 'value' => 'Username']);
|
||||
Form::addInputTypeText([
|
||||
'name' => 'login',
|
||||
'id' => 'login',
|
||||
'class' => 'form-control',
|
||||
'required' => 'required',
|
||||
'placeholder' => 'Type your name…',
|
||||
]);
|
||||
Form::addCloseDiv();
|
||||
|
||||
Form::addOpenDiv(['class' => 'form-group']);
|
||||
Form::addTypeLabel(['for' => 'password', 'value' => 'Password']);
|
||||
Form::addInputTypePassword([
|
||||
'name' => 'password',
|
||||
'id' => 'password',
|
||||
'class' => 'form-control',
|
||||
'required' => 'required',
|
||||
]);
|
||||
Form::addCloseDiv();
|
||||
|
||||
Form::addInputTypeSubmit(['value' => 'Sign in', 'class' => 'btn btn-primary']);
|
||||
|
||||
$html = Form::addForm([
|
||||
'method' => 'POST',
|
||||
'action' => '/login',
|
||||
'name' => 'loginForm',
|
||||
]);
|
||||
```
|
||||
Übergeben Sie `$html` an Ihre Ansicht:
|
||||
```php
|
||||
View::assign(['loginForm' => $html]);
|
||||
``````smarty
|
||||
<div class="card-body">
|
||||
{$loginForm nofilter}
|
||||
</div>
|
||||
```
|
||||
(`nofilter`, da `Form::addForm()` bereits gerenderte HTML zurückgibt – Smarty würde andernfalls entwerten.)
|
||||
|
||||
## Rezepte
|
||||
|
||||
### Auswahl mit Optionen
|
||||
```php
|
||||
Form::addSelect(['name' => 'country', 'class' => 'form-control', 'id' => 'country']);
|
||||
Form::addSelectOption(['value' => 'at', 'label' => 'Austria']);
|
||||
Form::addSelectOption(['value' => 'lu', 'label' => 'Luxembourg']);
|
||||
Form::addSelectOption(['value' => 'us', 'label' => 'United States']);
|
||||
```
|
||||
`addSelect` öffnet den `<select>` und fügt den Zustand der Optionensammlung zur Warteschlange hinzu; jede `addSelectOption` hängt an diese an; das umgebende `</select>` wird automatisch ausgegeben, wenn der nächste nicht-Optionen-Aufruf stattfindet.
|
||||
|
||||
### Radiogruppe
|
||||
```php
|
||||
foreach (['standard', 'admin', 'editor'] as $r) {
|
||||
Form::addInputTypeRadio([
|
||||
'name' => 'role', 'value' => $r, 'id' => "role-$r",
|
||||
]);
|
||||
Form::addTypeLabel(['for' => "role-$r", 'value' => ucfirst($r)]);
|
||||
}
|
||||
```
|
||||
### Dateiupload
|
||||
```php
|
||||
Form::addTypeFileUpload([
|
||||
'name' => 'avatar',
|
||||
'accept' => 'image/png,image/jpeg',
|
||||
]);
|
||||
```
|
||||
Achten Sie nicht auf `enctype` im Formular zu vergessen:
|
||||
```php
|
||||
Form::addForm([
|
||||
'method' => 'POST',
|
||||
'action' => '/profile/upload',
|
||||
'enctype' => 'multipart/form-data',
|
||||
]);
|
||||
```
|
||||
### Verborgener CSRF-Token
|
||||
```php
|
||||
Form::addTypeHidden([
|
||||
'name' => 'csrf',
|
||||
'value' => bin2hex(random_bytes(16)),
|
||||
]);
|
||||
```
|
||||
(Speichern Sie den Wert in `$_SESSION['csrf']` und überprüfen Sie ihn bei POST.)
|
||||
|
||||
## Layout-Hilfsprogramme
|
||||
|
||||
`addOpenDiv` / `addCloseDiv` und `addOpenAny` / `addCloseAny` ermöglichen es Ihnen, Bootstrap-artige Layouts innerhalb des gleichen flüssigen Streams zu erstellen:
|
||||
```php
|
||||
Form::addOpenDiv(['class' => 'row']);
|
||||
Form::addOpenDiv(['class' => 'col-md-6']);
|
||||
Form::addInputTypeText(['name' => 'first']);
|
||||
Form::addCloseDiv();
|
||||
Form::addOpenDiv(['class' => 'col-md-6']);
|
||||
Form::addInputTypeText(['name' => 'last']);
|
||||
Form::addCloseDiv();
|
||||
Form::addCloseDiv();
|
||||
```
|
||||
`addOpenAny([…, 'tag' => 'fieldset'])` öffnet einen beliebigen anderen Tag; `addCloseAny([…, 'tag' => 'fieldset'])` schließt ihn.
|
||||
|
||||
## Wie es im Hintergrund funktioniert
|
||||
|
||||
Die Formulardarstellung ist **zeichenkettenbasiert**, nicht DOM-basiert. Jede Typklasse befindet sich unter `core/c/type<X>.php` und enthält eine HTML-Vorlage mit Platzhaltern:
|
||||
```php
|
||||
// core/c/typetext.php
|
||||
private function _setElement() {
|
||||
$this->_element = '<input type="text" name="NAME" value="VALUE" '
|
||||
. 'placeholder="PLACEHOLDER" maxlength="MAXLENGTH" ID CLASS '
|
||||
. 'REQUIRED DATA>' . "\n";
|
||||
}
|
||||
```
|
||||
Der Fabrikprozess übergibt Ihren `$attributes`-Array an `FormAttributes::loadAttributeValues()`, das jedes Platzhalterzeichen durch den entsprechenden Wert ersetzt. Leere Werte werden gelöscht, sodass die Attribute nicht mit leeren Zeichenfolgen gerendert werden.
|
||||
|
||||
Dies ist der Grund, warum **nur bekannte Schlüssel funktionieren** — `'name'`, `'value'`, `'placeholder'`, `'class'`, `'id'`, `'required'`, `'data'` werden erkannt; beliebige Schlüssel werden stumm abgeworfen.
|
||||
|
||||
## Muster aus der Produktion
|
||||
|
||||
Der `formsController` auf `thorax.nibiru-framework.com` baut sein Kontaktformular im Konstruktor und weist es einer Eigenschaft zu:
|
||||
```php
|
||||
namespace Nibiru;
|
||||
use Nibiru\Adapter\Controller;
|
||||
use Nibiru\Factory\Form;
|
||||
|
||||
class formsController extends Controller {
|
||||
private string $form;
|
||||
|
||||
public function __construct() {
|
||||
parent::__construct();
|
||||
Form::create();
|
||||
Form::addTypeLabel(['value' => 'Full Name', 'for' => 'full-name']);
|
||||
Form::addInputTypeText([
|
||||
'name' => 'full-name',
|
||||
'id' => 'full-name',
|
||||
'required' => 'required',
|
||||
'class' => 'contacts-input form-control',
|
||||
]);
|
||||
// ...more fields...
|
||||
$this->form = Form::addForm([
|
||||
'name' => 'newregister',
|
||||
'method' => 'post',
|
||||
'action' => '/forms/submit',
|
||||
]);
|
||||
}
|
||||
|
||||
public function pageAction() {
|
||||
View::assign(['form' => $this->form]);
|
||||
}
|
||||
}
|
||||
```
|
||||
Dies behält die Aktionen des Controllers klein – das Formular ist eine Zeile, um im Template gerendert zu werden.
|
||||
|
||||
## Häufige Fallen
|
||||
|
||||
- **Versehen mit `Form::create()`.** Der Puffer ist statisch. Ohne `create()` werden Sie anhängen, was zuletzt da war (einschließlich über Anfragen in langlaufenden PHP-Prozessen).
|
||||
- **Smarty maskiert das HTML.** Fügen Sie `nofilter` (oder `|nofilter`) hinzu, wenn Sie die gerenderte Zeichenfolge ausgeben.
|
||||
- **Benutzerdefinierte Attribute werden stumm geschwiegen ignoriert.** Jeder Typ akzeptiert eine festgelegte Menge an Platzhalter-Schlüsseln. Verwenden Sie den `data`-Schlüssel für `data-*`-Attribute (die erweitert werden), aber wirklich willkürliche Attribute werden verworfen.
|
||||
- **Keine automatische XSS-Verschleierung.** Die Form-Ebene ist zeichenkettenbasiert. Wenn Sie Benutzereingaben als Standardwert `value` rendern, verschleiern Sie sie selbst vor dem Übergeben an die Fabrik.
|
||||
- **Verwirrung zwischen `addInputType…` und `addType…`.** Bei Zweifeln schauen Sie sich den Dateinamen in `core/c/type<X>.php` an — wenn das HTML-Element des Typs `<input type="X">` ist, verwenden Sie `addInputType<X>`, andernfalls `addType<X>`. `addSelect` ist die einzige Ausnahme (nur `addSelect`, ohne Präfix).
|
||||
|
||||
## Formularüberprüfung
|
||||
|
||||
Nibiru bietet keine serverseitige Validierung. Häufige Muster:
|
||||
|
||||
- `Respekt/Validierung` für deklarative Überprüfungen (bereits in vielen Produktionsanwendungen von Nibiru).
|
||||
- Ein Modul mit einem `validate()`-Plugin pro Formulartyp.
|
||||
- HTML5-Attribute wie `required`, `pattern`, `min`/`max` als erstes Schutzmaßnahme.
|
||||
153
docs/src/content/docs/de/core/models.md
Normal file
153
docs/src/content/docs/de/core/models.md
Normal file
@@ -0,0 +1,153 @@
|
||||
---
|
||||
title: "Modelle"
|
||||
description: "Wie Nibiru Model-Klassen aus Ihrem Datenbankschema automatisch generiert und wie Sie diese erweitern können."
|
||||
---
|
||||
|
||||
Nibiru's Model-Schicht ist *schema-first*. Sie schreiben keine Modelle von Hand – das Framework liest Ihre Datenbank und generiert für jede Tabelle eine PHP-Klasse bei jedem Start.
|
||||
|
||||
## Wie die Generierung funktioniert
|
||||
|
||||
Wenn `[GENERATOR] database = true` in Ihrer INI-Datei ist, führt der Dispatcher bei jeder Anfrage `new Model(false)` aus, was dazu führt, dass:
|
||||
|
||||
1. Verbindet sich mit dem aktiven Treiber.
|
||||
2. Listet die Tabellen in der konfigurierten Datenbank auf (`information_schema.tables` für PG, `SHOW TABLES` für MySQL).
|
||||
3. Für jede Tabelle schreibt es eine Datei `application/model/<table>.php`, die eine Klasse enthält, die den relevanten `Db` Adapter erweitert und eine `TABLE` Konstante einbettet, die die Spalten beschreibt.
|
||||
|
||||
Erneutes Ausführen ist günstig: Der Generator überschreibt nur dann, wenn das Schema geändert wurde, sodass eingetragene Modelle ihre manuell geschriebenen Methoden beibehalten *wenn* Sie `database = false` nach dem ersten Lauf setzen.
|
||||
```ini
|
||||
[GENERATOR]
|
||||
database = true ; regenerate models on each request
|
||||
database.overwrite = true ; if false, generator won't touch existing files
|
||||
```
|
||||
Im Produktionsumfeld setzen Sie `database = false`, damit die Modelle nicht bei jedem Aufruf neu generiert werden.
|
||||
|
||||
## Ein generiertes Modell
|
||||
|
||||
Für eine `users` Tabelle mit den Spalten `user_id, user_login, user_pass, user_email`:
|
||||
```php
|
||||
<?php
|
||||
namespace Nibiru\Model;
|
||||
use Nibiru\Adapter\MySQL\Db;
|
||||
|
||||
class users extends Db
|
||||
{
|
||||
const TABLE = [
|
||||
'table' => 'users',
|
||||
'field' => [
|
||||
'user_id' => 'user_id',
|
||||
'user_login' => 'user_login',
|
||||
'user_pass' => 'user_pass',
|
||||
'user_email' => 'user_email',
|
||||
],
|
||||
];
|
||||
|
||||
public function __construct() {
|
||||
self::initTable(self::TABLE);
|
||||
}
|
||||
}
|
||||
```
|
||||
Der `Db` Adapter bietet Ihnen einfache CRUD-Hilfsprogramme über `Pdo::` (oder den aktiven Treiber):
|
||||
```php
|
||||
$users = new \Nibiru\Model\users();
|
||||
|
||||
// Read by primary key
|
||||
$row = \Nibiru\Pdo::fetchRowInArrayById('users', ['user_id' => 42]);
|
||||
|
||||
// Read all
|
||||
$all = \Nibiru\Pdo::fetchAll('SELECT * FROM users WHERE user_account_active = 1');
|
||||
|
||||
// Insert
|
||||
\Nibiru\Pdo::insert('users', [
|
||||
'user_login' => 'marduk',
|
||||
'user_email' => 'marduk@nibiru.local',
|
||||
]);
|
||||
|
||||
// Update
|
||||
\Nibiru\Pdo::update('users',
|
||||
['user_email' => 'new@nibiru.local'],
|
||||
['user_id' => 42]);
|
||||
|
||||
// Delete
|
||||
\Nibiru\Pdo::delete('users', ['user_id' => 42]);
|
||||
```
|
||||
## Benutzerdefinierte Abfragen
|
||||
|
||||
Die generierte Klasse ist eine einfache PHP-Klasse – fügen Sie Methoden frei hinzu:
|
||||
```php
|
||||
namespace Nibiru\Model;
|
||||
use Nibiru\Adapter\MySQL\Db;
|
||||
use Nibiru\Pdo;
|
||||
|
||||
class documentation extends Db
|
||||
{
|
||||
const TABLE = [
|
||||
'table' => 'documentation',
|
||||
'field' => [
|
||||
'id' => 'id', 'title' => 'title', 'slug' => 'slug',
|
||||
'content' => 'content', 'category' => 'category',
|
||||
'version' => 'version',
|
||||
],
|
||||
];
|
||||
|
||||
public function __construct() { self::initTable(self::TABLE); }
|
||||
|
||||
public function getBySlug(string $slug): ?array {
|
||||
return Pdo::fetchRowInArrayById(
|
||||
self::TABLE['table'],
|
||||
[self::TABLE['field']['slug'] => $slug]
|
||||
) ?: null;
|
||||
}
|
||||
|
||||
public function search(string $query): array {
|
||||
$sql = 'SELECT * FROM ' . self::TABLE['table']
|
||||
. ' WHERE title LIKE :q OR content LIKE :q ORDER BY title';
|
||||
return Pdo::fetchAll($sql, [':q' => '%' . $query . '%']);
|
||||
}
|
||||
}
|
||||
```
|
||||
Wenn der Generator erneut mit `database.overwrite = true` ausgeführt wird, wird diese Datei ersetzt. Behalten Sie benutzerdefinierte Methoden in einer Kindklasse bei oder setzen Sie `database.overwrite = false` nach der ersten Generierung.
|
||||
|
||||
## Muster: dünnes Modell + Modul-Plugin
|
||||
|
||||
Für komplexe Domänen (Authentifizierung, Abrechnung, Analytik) platzieren die Showcase-Apps die Abfragemethoden auf einem **Modulplugin** anstatt direkt im Modell. Das Plugin besitzt die Geschäftsregeln; das Modell ist nur ein typisierter Handle für die Tabelle:
|
||||
```
|
||||
application/module/users/
|
||||
├── plugins/
|
||||
│ └── user.php # User::isAuthorized(), User::loginForm(), …
|
||||
└── traits/
|
||||
└── userForm.php
|
||||
``````php
|
||||
namespace Nibiru\Module\Users\Plugin;
|
||||
use Nibiru\Model\users;
|
||||
|
||||
class User {
|
||||
private users $usersModel;
|
||||
public function __construct() { $this->usersModel = new users(); }
|
||||
|
||||
public function isAuthorized(): bool {
|
||||
return isset($_SESSION['auth']['user_id']);
|
||||
}
|
||||
|
||||
public function findByLogin(string $login): ?array {
|
||||
return \Nibiru\Pdo::fetchRow(
|
||||
'SELECT * FROM users WHERE user_login = :login',
|
||||
[':login' => $login]
|
||||
) ?: null;
|
||||
}
|
||||
}
|
||||
```
|
||||
Dies behält die Controller dünn (`$this->user->isAuthorized()`), während das generierte Modell unverändert bleibt.
|
||||
|
||||
## Multifahrer-Hinweise
|
||||
|
||||
Der generierte `Db` Adapter ist treiberspezifisch. Wenn Sie von MySQL zu PostgreSQL wechseln, müssen Sie die Modelle neu generieren, sodass sie die richtige Basisklasse erweitern:
|
||||
|
||||
- MySQL / PDO → `Nibiru\Adapter\MySQL\Db`
|
||||
- PostgreSQL (libpq) → `Nibiru\Adapter\PostgreSQL\Db`
|
||||
- ODBC → `Nibiru\Adapter\Odbc\Db`
|
||||
|
||||
Die gleichen Abfragehilfen, aber ein anderer Adapter im Hintergrund.
|
||||
|
||||
## Wann der Generator übersprungen werden soll
|
||||
|
||||
Schreibgeschützte Datenbanken, Lieferanten-Systeme oder jedes Schema, das Sie nicht steuern: Setzen Sie `database = false`, schreiben Sie minimale Modellklassen, die den tatsächlich verwendeten Spalten entsprechen, und übertragen Sie sie in Ihr Versionskontrollsystem.
|
||||
245
docs/src/content/docs/de/core/modules.md
Normal file
245
docs/src/content/docs/de/core/modules.md
Normal file
@@ -0,0 +1,245 @@
|
||||
---
|
||||
title: "Module"
|
||||
description: "Erstellen von Nibiru-Modulen — das zweite M in MMVC. Merkmale, Plugins, Schnittstellen, Einstellungen, Beobachter."
|
||||
---
|
||||
|
||||
Ein **Modul** ist eine selbstständige Domänen-Einheit. Es besitzt seine Konfiguration, seine Plugins (Dienste), seine Traits (wiederverwendbare Methoden), seine Schnittstellen und optional einen eigenen MVC-Slice. Module sind die Art und Weise, wie Nibiru das *fat-controller*-Problem ohne einen Dienstcontainer vermeidet.
|
||||
|
||||
<figure>
|
||||
<img src="/img/modules-anatomy.png" alt="Botanische Anatomie eines Nibiru-Moduls, als geschichtete Blätter einer Lotus-Zehe gezeichnet — äußere Blätter, mittlere Blätter, innere Blätter, Kelchschicht, Empfänger." />
|
||||
<figcaption>Ein Modul, Schicht für Schicht.</figcaption>
|
||||
</figure>
|
||||
|
||||
## Anatomie
|
||||
```
|
||||
application/module/<name>/
|
||||
├── <name>.php # main class (implements IModule, optionally SplSubject)
|
||||
├── interfaces/ # contracts for plugins / external consumers
|
||||
│ └── <name>.php
|
||||
├── plugins/ # stateless services usable from controllers
|
||||
│ ├── <thing>.php
|
||||
│ └── <other>.php
|
||||
├── settings/ # auto-discovered .ini files
|
||||
│ ├── <name>.ini
|
||||
│ └── <name>.production.ini
|
||||
└── traits/ # reusable method groups
|
||||
└── <name>.php
|
||||
```
|
||||
Der `Registry` durchsucht `application/module/` beim Starten, entdeckt die jeweiligen Module in `settings/*.ini`, analysiert den Abschnitt, der mit dem Modulnamen (in Großbuchstaben) übereinstimmt, und speichert ihn für den Abruf über `Registry::getInstance()->loadModuleConfigByName('users')`.
|
||||
|
||||
## Ein minimales Modul
|
||||
```php
|
||||
<?php
|
||||
namespace Nibiru\Module\Users;
|
||||
|
||||
use Nibiru\Module as ModuleAdapter;
|
||||
use Nibiru\Interfaces\IModule;
|
||||
use Nibiru\Registry;
|
||||
|
||||
class Users extends ModuleAdapter implements IModule, \SplSubject
|
||||
{
|
||||
use Traits\Users;
|
||||
|
||||
const CONFIG_MODULE_NAME = 'users';
|
||||
|
||||
protected static \stdClass $usersRegistry;
|
||||
protected \SplObjectStorage $observers;
|
||||
|
||||
public function __construct() {
|
||||
$this->setUsersRegistry();
|
||||
$this->observers = new \SplObjectStorage();
|
||||
}
|
||||
|
||||
public function attach(\SplObserver $o): void { $this->observers->attach($o); }
|
||||
public function detach(\SplObserver $o): void { $this->observers->detach($o); }
|
||||
public function notify(): void {
|
||||
foreach ($this->observers as $o) { $o->update($this); }
|
||||
}
|
||||
|
||||
protected function setUsersRegistry(): void {
|
||||
self::$usersRegistry = Registry::getInstance()
|
||||
->loadModuleConfigByName(self::CONFIG_MODULE_NAME);
|
||||
}
|
||||
}
|
||||
```
|
||||
Die `IModule` Schnittstelle ist absichtlich ein Marker – der tatsächliche Verhalten befindet sich vollständig in Ihren Traits und Plugins.
|
||||
|
||||
## Plugins: Zustandslose Dienste
|
||||
|
||||
Ein **Plugin** ist eine Klasse, die Controller instanziieren können, um auf Modulfunktionen zuzugreifen:
|
||||
```php
|
||||
<?php
|
||||
// application/module/users/plugins/user.php
|
||||
namespace Nibiru\Module\Users\Plugin;
|
||||
|
||||
use Nibiru\Module\Users\Users;
|
||||
use Nibiru\Pdo;
|
||||
|
||||
class User extends Users
|
||||
{
|
||||
public function isAuthorized(): bool {
|
||||
return isset($_SESSION['auth']['user_id']);
|
||||
}
|
||||
|
||||
public function checkForStandardUser(): bool {
|
||||
return $this->isAuthorized()
|
||||
&& ($_SESSION['auth']['role'] ?? '') === 'standard';
|
||||
}
|
||||
}
|
||||
```
|
||||
In einem Controller:
|
||||
```php
|
||||
$this->user = new \Nibiru\Module\Users\Plugin\User();
|
||||
if (!$this->user->isAuthorized()) {
|
||||
View::forwardTo('/login');
|
||||
}
|
||||
```
|
||||
Plugins erben von der Modulklasse und teilen daher Zugriff auf den Registrierungsmechanismus, die Einstellungen und das Beobachtergerüst.
|
||||
|
||||
## Merkmale: wiederverwendbare Methoden
|
||||
|
||||
Ein **Trait** trägt wiederverwendbare Methodenkörper, die die Modulklassen benötigen. Häufiges Muster: Form-Factories.
|
||||
```php
|
||||
<?php
|
||||
// application/module/users/traits/users.php
|
||||
namespace Nibiru\Module\Users\Traits;
|
||||
|
||||
use Nibiru\Form;
|
||||
|
||||
trait Users
|
||||
{
|
||||
public function loginForm(): string {
|
||||
Form::create();
|
||||
Form::addOpenDiv(['class' => 'form-group']);
|
||||
Form::addInputTypeText([
|
||||
'class' => 'form-control', 'name' => 'login',
|
||||
'placeholder' => 'Username',
|
||||
]);
|
||||
Form::addCloseDiv();
|
||||
Form::addOpenDiv(['class' => 'form-group']);
|
||||
Form::addInputTypePassword([
|
||||
'class' => 'form-control', 'name' => 'password',
|
||||
'placeholder' => 'Password',
|
||||
]);
|
||||
Form::addCloseDiv();
|
||||
return Form::addForm([
|
||||
'method' => 'POST',
|
||||
'action' => '/login',
|
||||
'name' => 'loginForm',
|
||||
]);
|
||||
}
|
||||
}
|
||||
```
|
||||
Die Hauptklasse `Users` bringt es über `use Traits\Users;` ein.
|
||||
|
||||
## Modul-Einstellungen (INI)
|
||||
|
||||
Jeder Modul kann seine eigenen INI-Dateien haben. Der Registrierungsmechanismus analysiert jede `*.ini`-Datei im Verzeichnis `settings/` und sucht nach einem Abschnitt, der den Namen des Moduls (in Großbuchstaben) trägt:
|
||||
```ini
|
||||
; application/module/users/settings/users.ini
|
||||
[USERS]
|
||||
session.lifetime = 7200
|
||||
password.min.length = 12
|
||||
allowed.roles[] = "admin"
|
||||
allowed.roles[] = "editor"
|
||||
allowed.roles[] = "standard"
|
||||
```
|
||||
Lesen Sie es von überall aus:
|
||||
```php
|
||||
$cfg = \Nibiru\Registry::getInstance()->loadModuleConfigByName('users');
|
||||
$cfg->session_lifetime; // 7200
|
||||
$cfg->password_min_length; // 12
|
||||
$cfg->allowed_roles; // [admin, editor, standard]
|
||||
```
|
||||
Umgebungsüberschreibungen: Eine Datei mit dem Namen `users.production.ini` wird vorzugsweise gegenüber `users.ini` verwendet, wenn `APPLICATION_ENV=production`.
|
||||
|
||||
## Das Beobachter-Muster
|
||||
|
||||
Module, die `SplSubject` implementieren, können Ereignisse an angehängte Beobachter übertragen, ohne mit ihnen gekoppelt zu sein. Aus der Präsentation meldet das Modul `analytics` jedem angehängten Tracker bei jeder Seitenansicht:
|
||||
```php
|
||||
// in a controller
|
||||
$analytics = new \Nibiru\Module\Analytics\Analytics();
|
||||
$analytics->attach(new \Nibiru\Module\Analytics\Plugin\Matomo());
|
||||
$analytics->attach(new \Nibiru\Module\Analytics\Plugin\Plausible());
|
||||
|
||||
$analytics->trackPageView(); // internally calls notify()
|
||||
```
|
||||
Jeder Beobachter erhält die Analytics-Instanz und zieht die Ereignisdaten aus, über die er sich interessiert.
|
||||
|
||||
## Echte Produktionsmodule
|
||||
|
||||
Von den Showcase-Anwendungen:
|
||||
|
||||
- **`auth`** (TPMS) — Sitzungsverwaltung, QR-Code-Anmeldung, rollenbasierte Zugriffskontrolle. Implementiert `SplSubject`, sodass Anmelde-/Abmeldeereignisse an Protokollierungs- und Überwachungsmodule weitergegeben werden können.
|
||||
- **`cms`** (prod.maschinen-stockert.de) — Inhaltsverwaltung, indiziert durch *Controller-Pfad* + Sprache. Ermöglicht es Nicht-Entwicklern, die Seitenkopien zu aktualisieren.
|
||||
- **`graph_mail`** (TPMS) — Microsoft Graph API-Wrapper für transaktionale E-Mails.
|
||||
- **`pdfgenerator`** (prod.maschinen-stockert.de) — Generiert Maschinenkataloge aus DB-gesteuerten Vorlagen.
|
||||
- **`machineryscout`** — Elasticsearch-Indexverwaltung mit Merkmalen für Indizierung, Abfrage und erneute Indizierung.
|
||||
- **`assetmanager`** — zentrales CSS/JS-Pipeline, verwendet zum Wechseln von Themen je nach Sprache.
|
||||
- **`analytics`** — beobachtungsgetriebene Tracker-Ausbreitung (Matomo usw.).
|
||||
|
||||
## Modulregistrierung: Wie das Framework Ihr Modul findet
|
||||
|
||||
Ein Modulordner auf der Festplatte ist erforderlich, aber nicht ausreichend. Das Framework muss auch wissen, **wie es geladen wird** – das geschieht mit drei Positionierungsarrays in Ihrer `[AUTOLOADER]`-Konfiguration:
|
||||
```ini
|
||||
; application/settings/config/settings.development.ini
|
||||
|
||||
[AUTOLOADER]
|
||||
iface.pos[] = "users" ; load application/module/users/interfaces/
|
||||
iface.pos[] = "billing" ; load application/module/billing/interfaces/
|
||||
trait.pos[] = "users" ; load application/module/users/traits/
|
||||
trait.pos[] = "billing"
|
||||
class.pos[] = "users" ; load application/module/users/users.php
|
||||
class.pos[] = "billing"
|
||||
class.plugin.pos[] = "" ; reserved
|
||||
```
|
||||
Die Namen sind **kleinbuchstabeigeordnete Ordnername**, genau wie sie unter `application/module/` erscheinen. Der Frameworks `Auto::loader()` (aufgerufen vom [Dispatcher](/en/core/dispatcher/)) durchläuft jede Modul in der Reihenfolge und erfordert seine Dateien.
|
||||
|
||||
### Konvention für den Plugin-Namespace
|
||||
|
||||
Pluginklassen liegen im **Plural**-Namespace `Plugins`:
|
||||
```php
|
||||
// application/module/billing/plugins/invoice.php
|
||||
namespace Nibiru\Module\Billing\Plugins; // ← plural
|
||||
class Invoice extends \Nibiru\Module\Billing\Billing { /* ... */ }
|
||||
```
|
||||
Namespace und Klassenname nicht übereinstimmen und es treten Autoloader-Fehlschläge auf. Der CLI-Scaffold-Befehl (`./nibiru -m billing`) generiert den richtigen Namespace für Sie.
|
||||
|
||||
### Einstellungensermittlung
|
||||
|
||||
Sie **registrieren** Ihre Modul-INI-Dateien nicht — der [Registry](/de/core/registry/) entdeckt sie automatisch, indem er `application/module/<name>/settings/*.ini` durchläuft, nachdem `[AUTOLOADER]` die Modulklassen lädt. Jede Sektion `[<MODULE>]` (in Großbuchstaben) in einer INI wird zur Verfügung gestellt als:
|
||||
```php
|
||||
$cfg = \Nibiru\Registry::getInstance()->loadModuleConfigByName('billing');
|
||||
$cfg->invoice_prefix; // [BILLING] invoice.prefix → property
|
||||
```
|
||||
Der Registrierungsmodul bevorzugt `<Modul>.<Umgebung>.ini` (z.B. `billing.production.ini`), wenn `APPLICATION_ENV` übereinstimmt.
|
||||
|
||||
### Migrationen
|
||||
|
||||
Wenn Ihr Modul Datenbanktabellen hat, fügen Sie SQL-Dateien in `application/settings/config/database/` ein, die nummeriert sind und sich auf den bestehenden Bereich beziehen. Konvention des AI-Moduls:
|
||||
```
|
||||
200-ai_rag_collection.sql
|
||||
201-ai_rag_chunk.sql
|
||||
202-ai_conversation.sql
|
||||
203-ai_message.sql
|
||||
```
|
||||
Führen Sie mit `./nibiru -mi local` aus. Das Framework generiert automatisch Modelle in `application/model/<table>.php`, wenn `[GENERATOR] database = true` gesetzt ist, bereit zur Verwendung über `\Nibiru\Pdo::fetchAll(…)`.
|
||||
|
||||
## Ein Modul generieren
|
||||
```bash
|
||||
./nibiru -m billing
|
||||
```
|
||||
Skelette:
|
||||
```
|
||||
application/module/billing/
|
||||
├── billing.php
|
||||
├── interfaces/billing.php
|
||||
├── plugins/
|
||||
├── settings/billing.ini
|
||||
└── traits/
|
||||
```
|
||||
Fügen Sie den Schalter `-g` (`./nibiru -m billing -g`) hinzu, wenn Sie Graylog-Protokollierungshooks vordefiniert in die Struktur einfügen möchten.
|
||||
|
||||
## Wenn ein Modul **nicht** erstellt werden sollte
|
||||
|
||||
Verwenden Sie nicht jede Controller-Klasse als Modul. Der Schwellenwert ist: *haben Sie mindestens ein Trait, eine Erweiterung und einen INI-Schlüssel?* Wenn ja, verdient es sein eigenes Modulverzeichnis. Wenn nein, reicht ein freigegebener Trait-Datei oder eine kleine Hilfsdatei in `application/lib/`.
|
||||
93
docs/src/content/docs/de/core/pagination.md
Normal file
93
docs/src/content/docs/de/core/pagination.md
Normal file
@@ -0,0 +1,93 @@
|
||||
---
|
||||
title: "Seitennummerierung"
|
||||
description: "URL-basierte Paginierung mit Vorlagenhilfsprogrammen."
|
||||
---
|
||||
|
||||
Die Paginierung in Nibiru ist **URL-gesteuert**. Die Seitenzahl ist ein URL-Segment (`/products/index/page/3`), kein Abfragezeichenfolgen-Parameter. Die Klasse `Pageination` – achten Sie auf die Rechtschreibung – liest es, berechnet Offsets und weist ein `pagination`-Array in Smarty zu.
|
||||
|
||||
## Verbinden Sie es ein
|
||||
```php
|
||||
use Nibiru\Pageination;
|
||||
use Nibiru\Model\products;
|
||||
|
||||
class productsController extends Controller
|
||||
{
|
||||
public function pageAction() {
|
||||
$products = new products();
|
||||
Pageination::setEntriesPerPage(25); // optional; default from INI
|
||||
Pageination::setTable($products);
|
||||
$rows = Pageination::loadTableAsArray();
|
||||
|
||||
View::assign(['products' => $rows]);
|
||||
}
|
||||
|
||||
public function navigationAction() {
|
||||
JsonNavigation::getInstance()->loadJsonNavigationArray();
|
||||
}
|
||||
}
|
||||
```
|
||||
`Pageination::setTable(...)` liest die URL, berechnet Offsets und weist die Navigationsmetadaten über die Variable `pagination` dem Template zu.
|
||||
|
||||
## Rendern im Template
|
||||
```smarty
|
||||
{include 'shared/header.tpl'}
|
||||
<body>
|
||||
{include file="navigation.tpl"}
|
||||
|
||||
<main class="container">
|
||||
<table class="table">
|
||||
{foreach $products as $p}
|
||||
<tr><td>{$p.id}</td><td>{$p.name|escape}</td></tr>
|
||||
{/foreach}
|
||||
</table>
|
||||
|
||||
{include file="pageination.tpl"}
|
||||
</main>
|
||||
{include 'shared/footer.tpl'}
|
||||
</body>
|
||||
```
|
||||
Oder rendern Sie inline:
|
||||
```smarty
|
||||
<nav class="pagination">
|
||||
{if $pagination.previous}
|
||||
<a href="{$pagination.paginationPath}/page/{$pagination.previous}">←</a>
|
||||
{/if}
|
||||
|
||||
{foreach $pagination as $entry}
|
||||
{if isset($entry.page)}
|
||||
<a class="{if $entry.page == $pagination.current}active{/if}"
|
||||
href="{$pagination.paginationPath}/page/{$entry.page}">
|
||||
{$entry.page}
|
||||
</a>
|
||||
{/if}
|
||||
{/foreach}
|
||||
|
||||
{if $pagination.next}
|
||||
<a href="{$pagination.paginationPath}/page/{$pagination.next}">→</a>
|
||||
{/if}
|
||||
</nav>
|
||||
```
|
||||
## URL-Format
|
||||
```
|
||||
/<controller>/<action>/page/<N>
|
||||
```
|
||||
Beispiele:
|
||||
```
|
||||
/products/index/page/2
|
||||
/products/page/2 ; if action is omitted, "index" is implied
|
||||
/users/list/page/7
|
||||
```
|
||||
Die `paginationPath` Vorlagenvariable ist der Pfad *ohne* `/page/N` — fügen Sie Ihre eigene Seitenzahl hinzu, wenn Sie Links generieren.
|
||||
|
||||
## Konfiguration
|
||||
```ini
|
||||
[SETTINGS]
|
||||
entries.per.page = 25
|
||||
```
|
||||
Dies ist die globale Standardeinstellung; überschreiben Sie sie pro Controller mit `Pageination::setEntriesPerPage()` vor `setTable()`.
|
||||
|
||||
## Häufige Fallen
|
||||
|
||||
- **Rechtschreibung.** Die Klasse heißt `Pageination`, das Trait heißt `Attributes\Pageination`, die Vorlage heißt `pageination.tpl`. Es wird auf diese Weise in der gesamten Frameworks geschrieben. Streiten Sie sich nicht damit.
|
||||
- **`setTable()` Reihenfolge.** Rufen Sie `setEntriesPerPage()` *vor* `setTable()` auf, andernfalls werden die Offsets gegen den Standard berechnet.
|
||||
- **Seite 0 ist ungültig.** Wenn ein Benutzer `/page/0` anfordert, behandeln Sie es als `1`. Nibiru macht dies intern, aber wenn Sie eigene Paginierungslinks erstellen, normalisieren Sie die Eingaben.
|
||||
84
docs/src/content/docs/de/core/registry.md
Normal file
84
docs/src/content/docs/de/core/registry.md
Normal file
@@ -0,0 +1,84 @@
|
||||
---
|
||||
title: "Registrierung"
|
||||
description: "Wie Module automatisch entdeckt werden und ihre Konfigurationen für den Laufzeitzugriff zwischengespeichert werden."
|
||||
---
|
||||
|
||||
Der **Registry** ist der Modulkonfigurations-Cache von Nibiru. Beim Booten durchläuft er jedes Verzeichnis unter `application/module/`, analysiert die jeweilige `settings/*.ini`-Datei eines Moduls und macht die geparste Konfiguration über einen einzigen Accessor zugänglich. Denken Sie daran, dass es sich um das Gravitationszentrum handelt, das die Module gegeneinander in Orbit hält.
|
||||
|
||||
## Wie die Erkennung funktioniert
|
||||
|
||||
Für jedes Modul unter `application/module/<name>/settings/`:
|
||||
|
||||
1. Der Registrierungsprozess durchsucht Dateien mit dem Muster `*.ini`.
|
||||
2. Für jede Datei versucht er zunächst den **umgebungspezifischen** Dateinamen (`<base>.<env>.<ext>`, z.B. `users.production.ini`); falls dies nicht möglich ist, greift er auf den Basisnamen zurück (`users.ini`).
|
||||
3. Er ruft die Funktion `parse_ini_file($path, true)` (mehrere Abschnitte) auf.
|
||||
4. Er sucht nach einem Abschnitt mit dem Namen des Moduls, in **Großbuchstaben** — `[USERS]`, `[CMS]`, `[ANALYTICS]`.
|
||||
5. Die geparsten Schlüssel-Wert-Paare werden zu einem `\stdClass`-Objekt, das durch den Modulnamen indiziert ist.
|
||||
|
||||
## Ein Modul konfigurieren lesen
|
||||
```php
|
||||
$users = \Nibiru\Registry::getInstance()->loadModuleConfigByName('users');
|
||||
$users->session_lifetime;
|
||||
$users->password_min_length;
|
||||
$users->allowed_roles; // array
|
||||
```
|
||||
Innerhalb des Moduls selbst ist die Konvention, dies in einen Setter zu verpacken:
|
||||
```php
|
||||
class Users extends ModuleAdapter implements IModule
|
||||
{
|
||||
const CONFIG_MODULE_NAME = 'users';
|
||||
protected static \stdClass $usersRegistry;
|
||||
|
||||
protected function setUsersRegistry(): void {
|
||||
self::$usersRegistry = Registry::getInstance()
|
||||
->loadModuleConfigByName(self::CONFIG_MODULE_NAME);
|
||||
}
|
||||
|
||||
public static function lifetime(): int {
|
||||
return (int) self::$usersRegistry->session_lifetime;
|
||||
}
|
||||
}
|
||||
```
|
||||
## Warum Großbuchstaben in Abschnittsnamen verwendet werden?
|
||||
|
||||
INI-Abschnittsnamen werden üblicherweise in Großbuchstaben geschrieben, um sie optisch zu trennen. Der Registrierungsmechanismus setzt dies voraus, daher sucht ein Modul namens `cms` nach `[CMS]`. Verwenden Sie immer Großbuchstaben.
|
||||
|
||||
## Multi-INI pro Modul
|
||||
|
||||
Nichts hindert Sie daran, mehr als eine INI-Datei im Modulordner `settings/` zu haben. Der Registrierungsmechanismus analysiert alle Dateien und fügt alles zusammen, was mit `[<MODULE>]` übereinstimmt, in das gleiche `\stdClass`. Nützlich für die Aufteilung von Verantwortlichkeiten:
|
||||
```
|
||||
application/module/users/settings/
|
||||
├── users.ini # [USERS] base config
|
||||
├── users.smtp.ini # [USERS] mail-related keys
|
||||
└── users.production.ini # production override
|
||||
```
|
||||
## Wann der Registry gegenüber der Config verwendet werden sollte
|
||||
|
||||
| | `Config` | `Registry` |
|
||||
|---|---|---|
|
||||
| Quelle | `settings.<env>.ini` (eine Datei) | pro-Modul INIs (mehrere Dateien) |
|
||||
| Umfang | app-weit, Framework-Konfiguration | Modulspezifische Konfiguration |
|
||||
| Abschnitte | gemischt (DATABASE, SETTINGS, ENGINE…) | ein pro Modul |
|
||||
| Suche | `Config::getInstance()->getConfig()['DATABASE']['driver']` | `Registry::getInstance()->loadModuleConfigByName('users')` |
|
||||
|
||||
Ein Modul, das den `[DATABASE] Treiber` lesen muss, greift auf `Config` zu. Ein Modul, das seine eigene `[USERS] session_lifetime` liest, greift auf `Registry` zu.
|
||||
|
||||
## Auflistung aller geladenen Module
|
||||
```php
|
||||
$registry = \Nibiru\Registry::getInstance();
|
||||
foreach ($registry->getModuleNames() as $name) {
|
||||
$cfg = $registry->loadModuleConfigByName($name);
|
||||
echo "$name → " . print_r($cfg, true) . "\n";
|
||||
}
|
||||
```
|
||||
Nützlich in einem Debug-Controller oder einer Admin-Dashboard.
|
||||
|
||||
## Neuladen-Semantik
|
||||
|
||||
Wie `Config` ist das Registry ein Singleton, das **einmal** pro Anfrage aufgefüllt wird. Wenn Sie eine INI-Datei eines Moduls ändern, müssen Sie die Anfrage aktualisieren, um die neuen Werte zu sehen. Es gibt eine `destroy()`-Methode im Singleton-Registry, falls Sie wirklich während der Anfrage neu laden müssen, aber in den normalen Flows wird dies nicht passieren.
|
||||
|
||||
## Häufige Fallen
|
||||
|
||||
- **Abschnittsnamen stimmen nicht mit dem Modulordner überein.** Ordner `users/`, erwarteter Abschnitt `[USERS]`. Nicht übereinstimmend → leere Konfiguration.
|
||||
- **INI-Dateierweiterung.** Nur `*.ini` wird verarbeitet. Eine `*.conf` oder `*.config`-Datei wird ignoriert.
|
||||
- **Umgebungsdatei schattet Basisedatei stumm.** Wenn `users.production.ini` mit `[USERS]` existiert, schattet sie `users.ini` vollständig – nicht zusammengeführt. Behalten Sie beide Dateien synchron oder verwenden Sie eine + ein Overlay.
|
||||
106
docs/src/content/docs/de/core/routing.md
Normal file
106
docs/src/content/docs/de/core/routing.md
Normal file
@@ -0,0 +1,106 @@
|
||||
---
|
||||
title: "Routing"
|
||||
description: "Wie Nibiru URLs auf Controller, Aktionen und Parameter abbildet – einschließlich SEO-freundlicher URL-Formen."
|
||||
---
|
||||
|
||||
Die Nibiru-Routing ist **konventionenbasiert** mit optionalen Regex-Routen aus der Datei `settings.<env>.ini` für besondere Fälle. Es gibt keine große Route-Datei, die gepflegt werden muss.
|
||||
|
||||
## Die Standardskonvention
|
||||
```
|
||||
/<controller>/<action>/<param1>/<param2>/...
|
||||
```
|
||||
Beispiele:
|
||||
|
||||
| URL | Controller | Action | $_REQUEST |
|
||||
|---|---|---|---|
|
||||
| `/` | `indexController` | (none) | — |
|
||||
| `/products` | `productsController` | (none) | — |
|
||||
| `/products/detail` | `productsController` | `detailAction` | — |
|
||||
| `/products/detail/42` | `productsController` | `detailAction` | `id => 42` |
|
||||
| `/users/edit/42` | `usersController` | `editAction` | `id => 42` |
|
||||
|
||||
Ohne ein `_action` laufen nur `navigationAction()` und `pageAction()`. Mit `_action` läuft die benannte Aktion dazwischen.
|
||||
|
||||
## SEO-URLs
|
||||
|
||||
Nibiru erkennt SEO-freundliche URLs automatisch ohne jegliche Konfiguration. Das Muster:
|
||||
```
|
||||
/<controller>/<slug>/<numeric-id>
|
||||
```
|
||||
Wenn der zweite Abschnitt **kein** Methodenname im Controller ist und der dritte Abschnitt numerisch ist, überarbeitet Nibiru die Anfrage intern zu:
|
||||
```
|
||||
/<controller>/detail/?id=<numeric-id>&slug=<slug>
|
||||
```
|
||||
Also eine URL wie `/maschine/marduk-gold-plating/42` erreicht `maschineController::detailAction()` mit `$_REQUEST['id'] === '42'` und `$_REQUEST['slug'] === 'marduk-gold-plating'`. Dies ist genau so, wie die Produktions-E-Commerce-Seite `prod.maschinen-stockert.de` saubere URLs ohne einen benutzerdefinierten Router erzeugt.
|
||||
|
||||
## Parameter lesen
|
||||
|
||||
Alles nach dem Aktionssegment wird zu einem `$_REQUEST`-Schlüssel gemäß der Position:
|
||||
```php
|
||||
// /users/edit/42 → $_REQUEST['id'] = '42'
|
||||
public function editAction() {
|
||||
$id = (int) ($_REQUEST['id'] ?? 0);
|
||||
// ...
|
||||
}
|
||||
```
|
||||
Für nicht-numerische oder benannte Parameter bevorzugen Sie Abfragezeichenfolgen:
|
||||
```
|
||||
/users/search?q=marduk&page=2
|
||||
```
|
||||
## Benutzerdefinierte Routen über INI
|
||||
|
||||
Der Abschnitt `[NIBIRU_ROUTING]` in der Datei `settings.<env>.ini` ermöglicht es Ihnen, Regex-URL-Muster mit Controller/Aktion-Paaren und benannten Parametern zuzuordnen:
|
||||
```ini
|
||||
[NIBIRU_ROUTING]
|
||||
; Map /api/v1/products/42 to apiController::productsAction with id=42
|
||||
api.v1.products.pattern = "^/api/v1/products/(\d+)$"
|
||||
api.v1.products.controller = "api"
|
||||
api.v1.products.action = "products"
|
||||
api.v1.products.params[] = "id"
|
||||
```
|
||||
Wenn eine URL dem Muster entspricht, werden die Captured Groups den benannten `params[]` Schlüsseln (nach Reihenfolge) als `$_REQUEST` Einträge zugewiesen.
|
||||
|
||||
## Routen-Hilfsprogramme
|
||||
```php
|
||||
Router::getInstance()->currentPage(); // 'products'
|
||||
Router::getInstance()->tplName(); // 'products' (controller stem for templates)
|
||||
Router::getInstance()->getController(); // alias for currentPage()
|
||||
```
|
||||
Diese sind nützlich innerhalb von Controllern und Templates:
|
||||
```smarty
|
||||
<a href="/products" class="{if Router::currentPage() == 'products'}active{/if}">
|
||||
Products
|
||||
</a>
|
||||
```
|
||||
(In Smarty verwenden Sie ein Smarty-Plugin oder eine vordefinierte Variable; der Helper selbst ist für PHP.)
|
||||
|
||||
## Weiterleitung
|
||||
|
||||
Um auf Framework-Ebene umzuleiten (setzt die richtigen HTTP-Header und beendet das Skript):
|
||||
```php
|
||||
View::forwardTo('/login');
|
||||
```
|
||||
Für eine JSON-Antwort (setzt `Content-Type: application/json`):
|
||||
```php
|
||||
View::forwardToJsonHeader();
|
||||
View::assign(['data' => ['ok' => true]]);
|
||||
```
|
||||
Dieses Muster wird von API-Endpunkten im Produktivumfeld stark verwendet – siehe den `apiController` in `data.maschinen-stockert.de`.
|
||||
|
||||
## Paginierung-URLs
|
||||
|
||||
Nibirus [Pagination](/core/pagination/) erwartet URLs der Form:
|
||||
```
|
||||
/<controller>/<action>/page/<N>
|
||||
```
|
||||
Die `Pageination`-Klasse analysiert den nachfolgenden `page/N`-Segment, daher ist jede Routenformatierung in Ordnung, die es beibehält.
|
||||
|
||||
## Nachfolgende Schrägstriche und Groß-/Kleinschreibung
|
||||
|
||||
URLs werden **groß- und kleinschreibungssensitiv** abgeglichen. `/Users/edit` und `/users/edit` greifen auf unterschiedliche Controller zu (der zweite existiert, der erste führt zu einem 404-Fehler). Nachfolgende Schrägstriche werden toleriert.
|
||||
|
||||
## Häufige Fallen
|
||||
|
||||
- **Aktionssprünge mit SEO-URL.** Wenn Sie eine Methode `aboutAction()` benennen und versuchen, `/products/about/42` als SEO-URL zu verwenden, wird die SEO-Umleitung *nicht* ausgelöst, da `about` eine bekannte Aktion ist. Wählen Sie Slug-Namen, die nicht mit Aktionsnamen kollidieren.
|
||||
- **Aktion ohne `_action`.** Einfach tippen `/products/detail/` ruft **nicht** `detailAction()` auf — `_action` muss gesetzt sein. Der Dispatcher führt dies automatisch durch, wenn die URL mindestens zwei Segmente hat, aber eine abgeschnittene Abfragezeichenfolge nicht.
|
||||
- **`index` ist der Stammcontroller.** `/` → `indexController`. Es gibt keine separate "Home"-Route.
|
||||
163
docs/src/content/docs/de/core/views.md
Normal file
163
docs/src/content/docs/de/core/views.md
Normal file
@@ -0,0 +1,163 @@
|
||||
---
|
||||
title: "Ansichten & Smarty"
|
||||
description: "Wie `.tpl` Vorlagen aufgelöst werden, der `View::assign` Pipeline, das Caching und die gemeinsamen Partialen."
|
||||
---
|
||||
|
||||
Ansichten in Nibiru sind **Smarty 3** Vorlagen. Jeder Controller hat eine Standardvorlage; eingebettete Aktionen können ihre eigenen haben. Das Singleton `View` umhüllt den Smarty-Engine und macht einen globalen Hilfsprogramm verfügbar: `View::assign()`.
|
||||
|
||||
## Wo sich die Templates befinden
|
||||
```
|
||||
application/view/
|
||||
├── templates/ # source .tpl files (you write these)
|
||||
│ ├── index.tpl # → indexController::pageAction()
|
||||
│ ├── products.tpl # → productsController::pageAction()
|
||||
│ ├── products/
|
||||
│ │ └── detail.tpl # → productsController::detailAction()
|
||||
│ ├── shared/
|
||||
│ │ ├── header.tpl
|
||||
│ │ ├── footer.tpl
|
||||
│ │ └── meta.tpl
|
||||
│ ├── navigation.tpl
|
||||
│ └── pageination.tpl # (note the spelling)
|
||||
├── templates_c/ # Smarty compile cache (auto)
|
||||
├── cache/ # rendered HTML cache (when caching=true)
|
||||
└── mockup/ # static design mockups
|
||||
```
|
||||
`templates_c/` wird von Smarty erstellt und verwaltet. **Sie muss vom Benutzer Ihrer Webserver-Instanz beschreibbar sein.** `cache/` wird nur verwendet, wenn `[ENGINE] caching = true`.
|
||||
|
||||
## Auflösungsregeln
|
||||
|
||||
Die Anzeigebene löst den Vorlagenpfad aus dem übereinstimmenden Controller heraus:
|
||||
|
||||
- Für `pageAction()` → `templates/<controller>.tpl`.
|
||||
- Für eine benannte Aktion `fooAction()` → `templates/<controller>/foo.tpl`, falls sie existiert, andernfalls `templates/<controller>.tpl`.
|
||||
|
||||
Falls keines davon funktioniert, wird die [weiche 404](/core/dispatcher/#soft-404)-Fehlervorlage gerendert.
|
||||
|
||||
## Ein minimalistisches Template
|
||||
```smarty
|
||||
{include 'shared/header.tpl'}
|
||||
<body>
|
||||
{include file="navigation.tpl"}
|
||||
|
||||
<main class="container">
|
||||
<h1>{$title|escape}</h1>
|
||||
<ul>
|
||||
{foreach $products as $p}
|
||||
<li><a href="/products/detail/{$p.id}">{$p.name|escape}</a></li>
|
||||
{/foreach}
|
||||
</ul>
|
||||
</main>
|
||||
|
||||
{include 'shared/footer.tpl'}
|
||||
</body>
|
||||
```
|
||||
`{include 'shared/header.tpl'}` und `{include file="navigation.tpl"}` sind beide gültige Smarty-Include-Formen.
|
||||
|
||||
## View::assign
|
||||
|
||||
`View::assign()` ist die Art und Weise, wie PHP-Daten in Templates gelangen. Es ist statisch, idempotent und arbeitet gut mit Arrays zusammen:
|
||||
```php
|
||||
View::assign(['title' => 'Products']);
|
||||
View::assign([
|
||||
'products' => $list,
|
||||
'count' => count($list),
|
||||
]);
|
||||
```
|
||||
Spätere Aufrufe überschreiben frühere für denselben Schlüssel. Innerhalb der Vorlagen werden diese zu `{$title}`, `{$products}`, `{$count}`.
|
||||
|
||||
Es gibt keine manuell aufzurufende `View::display()` – der Dispatcher ruft automatisch `Display::display()` auf, nachdem `pageAction()` zurückgegeben wurde.
|
||||
|
||||
## Freigegebene CSS/JS-Injektion
|
||||
|
||||
Die Konvention, die in den Showcase-Anwendungen verwendet wird, besteht darin, die konfigurierte Asset-Liste in die Vorlage zu übertragen:
|
||||
```php
|
||||
View::assign([
|
||||
'css' => Config::getInstance()->getConfig()[View::NIBIRU_SETTINGS]['smarty.css'],
|
||||
'js' => Config::getInstance()->getConfig()[View::NIBIRU_SETTINGS]['smarty.js'],
|
||||
]);
|
||||
``````smarty
|
||||
{* shared/header.tpl *}
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>{$title|escape}</title>
|
||||
{foreach $css as $stylesheet}
|
||||
<link rel="stylesheet" href="{$stylesheet}">
|
||||
{/foreach}
|
||||
</head>
|
||||
```
|
||||
Dann ist `[EINSTELLUNGEN] smarty.css[]` in der INI-Datei die einzige Quelle der Wahrheit für Stylesheets.
|
||||
|
||||
## Zwischenspeicherung
|
||||
|
||||
Das Cachen ist optional:
|
||||
```ini
|
||||
[ENGINE]
|
||||
caching = true
|
||||
```
|
||||
Wenn aktiviert, speichert Smarty das gerenderte HTML im Verzeichnis `application/view/cache/` mit der Lebensdauer `Smarty::CACHING_LIFETIME_CURRENT` (Standard ≈ 1 Stunde). Löschen Sie den Cache mit:
|
||||
```bash
|
||||
./nibiru -cache-clear
|
||||
```
|
||||
Dies löscht sowohl `templates_c/` als auch `cache/` sicher.
|
||||
|
||||
::: caution
|
||||
Gecachte Seiten führen Ihren Controller nicht aus. Verwenden Sie keine Caching für Seiten mit benutzerspezifischem Inhalt oder CSRF-Token.
|
||||
:::
|
||||
|
||||
## Grundlagen von Smarty
|
||||
|
||||
Dinge, die Sie täglich benötigen:
|
||||
```smarty
|
||||
{* variables *}
|
||||
{$user.name}
|
||||
{$products[0].title}
|
||||
|
||||
{* iteration *}
|
||||
{foreach $items as $item}
|
||||
...
|
||||
{foreachelse}
|
||||
No items.
|
||||
{/foreach}
|
||||
|
||||
{* conditionals *}
|
||||
{if $count > 0}…{else}…{/if}
|
||||
|
||||
{* string filters *}
|
||||
{$body|escape} {* HTML escape *}
|
||||
{$date|date_format:"%Y-%m-%d"}
|
||||
{$price|string_format:"%.2f"}
|
||||
|
||||
{* includes *}
|
||||
{include file="shared/header.tpl" title=$title}
|
||||
|
||||
{* assigning *}
|
||||
{assign var="now" value=time()}
|
||||
```
|
||||
## JSON-Antworten
|
||||
|
||||
Wenn eine Aktion `View::forwardToJsonHeader()` aufruft, wird Smarty umgangen und Nibiru gibt den zugewiesenen `data`-Schlüssel als JSON aus. Nützlich für AJAX-Endpunkte:
|
||||
```php
|
||||
public function searchAction() {
|
||||
View::forwardToJsonHeader();
|
||||
$results = $this->index->search($_REQUEST['q'] ?? '');
|
||||
View::assign(['data' => $results]);
|
||||
}
|
||||
```
|
||||
## Navigationsumfang beinhaltet
|
||||
|
||||
`{include file="navigation.tpl"}` liest aus der im Abschnitt `[SETTINGS] navigation` konfigurierten JSON-Datei. Produktionsanwendungen laden oft **mehrere** benannte Navigationsarrays:
|
||||
```php
|
||||
public function navigationAction() {
|
||||
JsonNavigation::getInstance()->loadJsonNavigationArray('headnavigation');
|
||||
JsonNavigation::getInstance()->loadJsonNavigationArray('mainnavigation');
|
||||
JsonNavigation::getInstance()->loadJsonNavigationArray('footer');
|
||||
}
|
||||
```
|
||||
Jeder benannte Array wird zu einer Smarty-Variablen mit dem gleichen Namen, bereit zur Darstellung im entsprechenden Partial.
|
||||
|
||||
## Häufige Probleme
|
||||
|
||||
- **`templates_c/` Berechtigungen** — wenn Smarty nicht darin schreiben kann, erhalten Sie einen schwerwiegenden Fehler. Führen Sie einmal `./nibiru -s` aus, um dies zu beheben.
|
||||
- **`{$variable}` ohne Wert** — in der Standardmodus rendert Smarty nichts anstelle eines Throwns. Aktivieren Sie während der Entwicklung `error_reporting = E_ALL` und `[ENGINE] debug = true`, um Tippfehler zu erkennen.
|
||||
- **HTML-Escape** — Smarty führt nicht automatisch das Escape durch. Verwenden Sie `|escape` für jede vom Benutzer gesteuerte Zeichenkette.
|
||||
215
docs/src/content/docs/de/design/components.md
Normal file
215
docs/src/content/docs/de/design/components.md
Normal file
@@ -0,0 +1,215 @@
|
||||
---
|
||||
title: "Komponenten"
|
||||
description: "Schaltflächen, Karten, Aufrufe, Heldenbildschirm – bereit zum Kopieren und Einfügen."
|
||||
---
|
||||
|
||||
## Primärer Button
|
||||
|
||||
Ein schwarzer Rechteck auf einem cremefarbenen Hintergrund. Editorial. Kein Gradient, kein Glühen.
|
||||
```html
|
||||
<a class="atelier-button atelier-button--primary" href="/en/start/">
|
||||
<span>Read the docs</span>
|
||||
<span class="atelier-button__arrow" aria-hidden="true">→</span>
|
||||
</a>
|
||||
``````css
|
||||
.atelier-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.7rem 1.2rem;
|
||||
font-family: var(--nibiru-font-text);
|
||||
font-variation-settings: var(--nibiru-fv-body-bold);
|
||||
font-size: 0.92rem;
|
||||
letter-spacing: var(--nibiru-tracking-body);
|
||||
border-radius: var(--nibiru-radius-md);
|
||||
text-decoration: none;
|
||||
transition: transform 200ms var(--nibiru-ease-out),
|
||||
box-shadow 200ms var(--nibiru-ease-out);
|
||||
}
|
||||
|
||||
.atelier-button--primary {
|
||||
background: var(--nibiru-ink);
|
||||
color: var(--nibiru-paper);
|
||||
border: 1px solid var(--nibiru-ink);
|
||||
box-shadow: 0 1px 0 rgba(31, 27, 46, 0.4);
|
||||
}
|
||||
|
||||
.atelier-button--primary:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 8px 24px -8px rgba(31, 27, 46, 0.5);
|
||||
}
|
||||
|
||||
.atelier-button .atelier-button__arrow {
|
||||
transition: transform 240ms var(--nibiru-ease-out);
|
||||
}
|
||||
.atelier-button:hover .atelier-button__arrow {
|
||||
transform: translateX(3px);
|
||||
}
|
||||
```
|
||||
## Geisterknopf
|
||||
```css
|
||||
.atelier-button--ghost {
|
||||
background: transparent;
|
||||
color: var(--nibiru-ink);
|
||||
border: 1px solid var(--nibiru-ink-faint);
|
||||
}
|
||||
.atelier-button--ghost:hover {
|
||||
border-color: var(--nibiru-iris);
|
||||
color: var(--nibiru-iris-deep);
|
||||
}
|
||||
```
|
||||
## Karte
|
||||
|
||||
Flach, papierfarbig. Bei der Bewegung hebt sich das Element um 2px und die Grenze wird heller.
|
||||
```html
|
||||
<article class="card">
|
||||
<h3 class="card-title">MMVC architecture</h3>
|
||||
<p>Modules wrap MVC with traits, plugins, interfaces and settings.</p>
|
||||
</article>
|
||||
``````css
|
||||
.card {
|
||||
background: var(--nibiru-mist);
|
||||
border: 1px solid rgba(31, 27, 46, 0.10);
|
||||
border-radius: var(--nibiru-radius-lg);
|
||||
padding: 1.6rem 1.5rem;
|
||||
transition: transform 240ms var(--nibiru-ease-out),
|
||||
border-color 240ms,
|
||||
box-shadow 240ms;
|
||||
}
|
||||
.card:hover {
|
||||
transform: translateY(-2px);
|
||||
border-color: var(--nibiru-iris);
|
||||
box-shadow: 0 18px 40px -20px rgba(94, 84, 140, 0.35);
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-family: var(--nibiru-font-display);
|
||||
font-variation-settings: var(--nibiru-fv-display-medium);
|
||||
font-size: 1.05rem;
|
||||
letter-spacing: -0.018em;
|
||||
color: var(--nibiru-ink);
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
|
||||
.card p {
|
||||
color: var(--nibiru-ink-soft);
|
||||
font-size: 0.92rem;
|
||||
line-height: 1.6;
|
||||
margin: 0;
|
||||
}
|
||||
```
|
||||
## Zitat-Aufcall
|
||||
|
||||
Eine einzelpixelige violette Linie auf der linken Seite. Kein Rahmen. Kein Symbol.
|
||||
```html
|
||||
<aside class="callout callout--note">
|
||||
<span class="callout__title">Note</span>
|
||||
<p>Browse <code>/start/quick-start/</code> for a five-minute first build.</p>
|
||||
</aside>
|
||||
``````css
|
||||
.callout {
|
||||
background: var(--nibiru-mist);
|
||||
border: 0;
|
||||
border-left: 2px solid var(--nibiru-iris);
|
||||
padding: 1rem 1.2rem 1rem 1.4rem;
|
||||
border-radius: 0;
|
||||
margin: 1.6rem 0;
|
||||
}
|
||||
.callout--tip { border-left-color: var(--nibiru-aurum); }
|
||||
.callout--caution { border-left-color: var(--nibiru-aurum); }
|
||||
.callout--danger { border-left-color: var(--nibiru-rose); }
|
||||
|
||||
.callout__title {
|
||||
font-family: var(--nibiru-font-text);
|
||||
font-variation-settings: var(--nibiru-fv-label);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: var(--nibiru-tracking-label);
|
||||
font-size: 0.72rem;
|
||||
color: var(--nibiru-ink-faint);
|
||||
display: block;
|
||||
margin-bottom: 0.45rem;
|
||||
}
|
||||
```
|
||||
## Inline code
|
||||
|
||||
Keine Box. Eine farbige Unterstreichung, die im Hintergrund bleibt.
|
||||
```css
|
||||
:not(pre) > code {
|
||||
font-family: var(--nibiru-font-mono);
|
||||
font-size: 0.86em;
|
||||
background: linear-gradient(to top,
|
||||
rgba(124, 112, 171, 0.16) 35%,
|
||||
transparent 35%);
|
||||
color: var(--nibiru-iris-deep);
|
||||
padding: 0 0.18em;
|
||||
border-radius: 2px;
|
||||
}
|
||||
```
|
||||
## Held mit der Lotus
|
||||
|
||||
Asymmetrische Gitter mit zwei Spalten. Große Editorial-Nummer hinter dem Text. Das Branding verankert sich auf der rechten Seite und atmet alle 18 Sekunden ein.
|
||||
```html
|
||||
<section class="atelier-hero">
|
||||
<span class="atelier-hero__number" aria-hidden="true">01</span>
|
||||
|
||||
<div class="atelier-hero__grid">
|
||||
<div>
|
||||
<p class="atelier-hero__eyebrow">Modular MMVC PHP framework</p>
|
||||
<h1 class="atelier-hero__title">
|
||||
Create.<br>Invent.<br><em>Impress.</em>
|
||||
</h1>
|
||||
<p class="atelier-hero__lede">
|
||||
Nibiru is a modular PHP framework for builders who ship.
|
||||
</p>
|
||||
<div class="atelier-hero__cta">
|
||||
<a class="atelier-button atelier-button--primary" href="/en/start/">
|
||||
Read the docs <span aria-hidden="true">→</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="atelier-hero__art" aria-hidden="true">
|
||||
<img class="atelier-hero__mark" src="/img/nibiru-logo.png" alt="">
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
```
|
||||
Die vollständigen Stile befinden sich in `src/styles/nibiru.css`.
|
||||
|
||||
## Oracle Launcher
|
||||
|
||||
Ein 52px kreisförmiger Papierteller im unteren rechten Bereich. Das Lotus-Symbol innerhalb dreht sich beim Hovern. Kein Puls. Kein Leuchten.
|
||||
```html
|
||||
<button id="oracle-launcher" type="button" aria-label="Open Oracle"></button>
|
||||
``````css
|
||||
#oracle-launcher {
|
||||
position: fixed;
|
||||
bottom: 1.4rem; right: 1.4rem;
|
||||
width: 52px; height: 52px; border-radius: 50%;
|
||||
border: 1px solid rgba(31, 27, 46, 0.18);
|
||||
background: var(--nibiru-paper);
|
||||
box-shadow: 0 6px 24px -10px rgba(31, 27, 46, 0.35),
|
||||
0 1px 2px rgba(31, 27, 46, 0.08);
|
||||
cursor: pointer;
|
||||
transition: transform 220ms var(--nibiru-ease-out),
|
||||
box-shadow 220ms,
|
||||
border-color 220ms;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
|
||||
#oracle-launcher::before {
|
||||
content: '';
|
||||
width: 26px; height: 26px;
|
||||
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'><path d='M12 2 C 9 8, 9 16, 12 22 C 15 16, 15 8, 12 2 Z' fill='%237c70ab'/><path d='M2 12 C 8 9, 16 9, 22 12 C 16 15, 8 15, 2 12 Z' fill='%237db7dc'/></svg>");
|
||||
background-size: contain;
|
||||
transition: transform 400ms var(--nibiru-ease-out);
|
||||
}
|
||||
|
||||
#oracle-launcher:hover {
|
||||
transform: translateY(-2px);
|
||||
border-color: var(--nibiru-iris);
|
||||
}
|
||||
#oracle-launcher:hover::before {
|
||||
transform: rotate(60deg);
|
||||
}
|
||||
```
|
||||
79
docs/src/content/docs/de/design/motion.md
Normal file
79
docs/src/content/docs/de/design/motion.md
Normal file
@@ -0,0 +1,79 @@
|
||||
---
|
||||
title: "Bewegung"
|
||||
description: "Wie Dinge auf den Nibiru-Sites bewegen sich – langsam, leise und nicht sehr oft."
|
||||
---
|
||||
|
||||
## Prinzipien
|
||||
|
||||
- **Weniger ist mehr.** Ein einziger 18-Sekunden-Atemzug ist besser als drei Pulse, vier Drehungen und einen Fade-up.
|
||||
- **Verändert nur.** Animieren Sie `transform` und `opacity`. Vermeiden Sie `top`, `width`, `height` – sie verursachen Layoutarbeit.
|
||||
- **Respektiert `prefers-reduced-motion`.** Jeder Schlüsselrahmen im System ist durch eine Medienabfrage geschützt.
|
||||
- **Hovern gibt Bewegung.** Ruhezustände sind still. Bewegung geschieht, wenn Sie *etwas tun* – hovern Sie auf eine Karte, klicken Sie auf den Oracle.
|
||||
|
||||
## Tokenreferenz
|
||||
|
||||
| Token | Wert | Verwendung |
|
||||
|---|---|---|
|
||||
| `--nibiru-duration-fast` | 150ms | Inline-Übergänge (Links, Haarstrichverschiebungen) |
|
||||
| `--nibiru-duration-normal` | 220ms | Hover-Lifts, Button-Transformationen, Karten-Glänzen |
|
||||
| `--nibiru-duration-slow` | 400ms | Modale Panels, Oracle-Aufdeckung |
|
||||
| `--nibiru-duration-breathe` | 18s | Hero Lotus Atemzug |
|
||||
| `--nibiru-ease-out` | `cubic-bezier(0.2, 0.7, 0.2, 1)` | Standard-Ease-Out |
|
||||
| `--nibiru-ease-spring` | `cubic-bezier(0.34, 1.56, 0.64, 1)` | Für die Aufsteigerung des Oracle-Panels reserviert |
|
||||
|
||||
## Der Atem
|
||||
|
||||
Ein Sechspixeraufstieg, eine Bruchteildes Grades Rotation, in über 18 Sekunden. Der Lotus lebt, aber fragt nicht nach Aufmerksamkeit.
|
||||
```css
|
||||
@keyframes atelier-breathe {
|
||||
0%, 100% { transform: translateY(0) rotate(0deg); }
|
||||
50% { transform: translateY(-6px) rotate(0.6deg); }
|
||||
}
|
||||
.atelier-hero__mark {
|
||||
animation: atelier-breathe 18s ease-in-out infinite;
|
||||
}
|
||||
```
|
||||
## Der Aufstieg des Orakels
|
||||
|
||||
220ms Eingang. Eine kleine `translateY` und `scale(0.99)`, sodass es sich anfühlt, als ob es wächst, nicht aufspringt.
|
||||
```css
|
||||
@keyframes oracle-rise {
|
||||
from { opacity: 0; transform: translateY(10px) scale(0.99); }
|
||||
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||
}
|
||||
#oracle-panel.is-open {
|
||||
animation: oracle-rise 220ms cubic-bezier(0.2, 0.7, 0.2, 1);
|
||||
}
|
||||
```
|
||||
## Der Hoverschlepper
|
||||
|
||||
Karten und die primäre Schaltfläche heben sich bei der Bewegung um 1–2px hoch, mit einem tiefen Schatten.
|
||||
```css
|
||||
.card {
|
||||
transition: transform 240ms var(--nibiru-ease-out),
|
||||
box-shadow 240ms var(--nibiru-ease-out),
|
||||
border-color 240ms;
|
||||
}
|
||||
.card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--nibiru-shadow-lg);
|
||||
}
|
||||
```
|
||||
## Reduzierte Bewegung
|
||||
```css
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.atelier-hero__mark,
|
||||
.card,
|
||||
#oracle-panel.is-open {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
```
|
||||
Die Seite sieht weiterhin richtig aus, wenn die Bewegung deaktiviert ist – Animationen sind nur Dekoration und niemals Informationen.
|
||||
|
||||
## Verboten
|
||||
|
||||
- Pulsieren Sie nicht. Der Oracle ist ein stummer Tintenstempel, kein Feuerwehralarm.
|
||||
- Verwenden Sie keine mehreren Animationen auf dem gleichen Element. Wählen Sie eine aus.
|
||||
- Animieren Sie nicht länger als 400 ms für Benutzerinteraktionen. Klicks sollten sofortig wirken.
|
||||
- Verwenden Sie keine Spring-/Überschwingungsease für den Helden – nur für kleine „Dinge-gerade-angekommen“-Momente.
|
||||
54
docs/src/content/docs/de/design/overview.md
Normal file
54
docs/src/content/docs/de/design/overview.md
Normal file
@@ -0,0 +1,54 @@
|
||||
---
|
||||
title: "Das Atelier Design-System"
|
||||
description: "Eine neobotanische visuelle Sprache für das Nibiru-Framework – ein Lotus auf crememfarbem Papier, beleuchtet von Morgenschein."
|
||||
---
|
||||
|
||||
Das **Atelier**-Designsystem ist die visuelle Sprache hinter dieser Website und der Nibiru-Marke. Es wird aus dem Lotus-Symbol im Brand-Logo und der warmen Kremfarbe gezogen, auf der das Wortmark steht. Das System wird als portables Design-Token bereitgestellt – CSS, SCSS, JSON und Tailwind – sodass jede Nibiru-Website, interne Tool oder Partnerprojekt den Stil annehmen kann, ohne ihn neu zu erfinden.
|
||||
|
||||
> Eine Lotus auf crememfarbiger Papier, beleuchtet von Morgenlicht.
|
||||
|
||||
## Fünf Prinzipien
|
||||
|
||||
1. **Eine Familie.** Jedes Textelement verwendet **Bricolage Grotesque** (variable Font, Google Fonts). Unterschiedliche Größen verwenden unterschiedliche optische-Größe-Achsenwerte — Anzeigegrößen sind zeichnerisch, Körperschriftengrößen sind ruhig. Keine Serifen, niemals.
|
||||
2. **Kremig, nicht weiß.** Die Seite ist `#f5f1e8` — warmes Papier. Reines Weiß ist zu kalt. Reines Schwarz ist zu laut. Wir verwenden einen tiefen Indigo-Schwarz `#1f1b2e` für den Text anstelle dessen.
|
||||
3. **Zwei Blätter, ein Goldblatt.** Marke-Violett `#7c70ab` und Marke-Himmelblau `#7db7dc` kommen direkt aus dem Lotus. Ein einzelner eingeschränkter Goldakkzent `#c9a96e` wird für Momente reserviert, die ihn verdienen.
|
||||
4. **Redakteurische Abstände.** Generöser Whitespaces, asymmetrische Layouts, Atemende Absätze. Nähert sich einem veröffentlichten Monographie als einer SaaS-Landingpage.
|
||||
5. **Reduzierte Bewegung.** Dinge bewegen sich langsam, wenn sie sich überhaupt bewegen. Der Held-Lotus atmet alle 18 Sekunden einmal. Kein Zittern, kein Flackern, kein Pulsieren.
|
||||
|
||||
## Die Token abrufen
|
||||
```bash
|
||||
# CSS custom properties
|
||||
curl -O https://nibiru-framework.com/design-system/tokens.css
|
||||
|
||||
# SCSS variables and maps
|
||||
curl -O https://nibiru-framework.com/design-system/tokens.scss
|
||||
|
||||
# Tailwind preset
|
||||
curl -O https://nibiru-framework.com/design-system/tailwind.preset.js
|
||||
```
|
||||
Alle Token sind mit einem Namespace versehen (`--nibiru-*` / `nibiru.*` / `nibiru-*`).
|
||||
|
||||
## In 12 Zeilen annehmen
|
||||
```html
|
||||
<link rel="stylesheet" href="https://nibiru-framework.com/design-system/tokens.css">
|
||||
<style>
|
||||
body { background: var(--nibiru-paper); color: var(--nibiru-ink);
|
||||
font-family: var(--nibiru-font-text);
|
||||
font-variation-settings: var(--nibiru-fv-body);
|
||||
letter-spacing: var(--nibiru-tracking-body); }
|
||||
h1 { font-variation-settings: var(--nibiru-fv-display-hero);
|
||||
letter-spacing: var(--nibiru-tracking-display);
|
||||
font-size: var(--nibiru-text-hero); }
|
||||
.cta { background: var(--nibiru-ink); color: var(--nibiru-paper);
|
||||
padding: 0.7rem 1.2rem;
|
||||
border-radius: var(--nibiru-radius-md); }
|
||||
</style>
|
||||
```
|
||||
Das ist ausreichend, um die Marke zu wahren.
|
||||
|
||||
## Was hier dokumentiert ist
|
||||
|
||||
- [Palette](/de/design/palette/) — jede Farbe mit ihrer Rolle.
|
||||
- [Typography](/de/design/typography/) — die verwendeten Variablenachsen von Bricolage ernst genommen.
|
||||
- [Komponenten](/de/design/components/) — Schaltflächen, Karten, Aufrufe, der Oracle-Launcher.
|
||||
- [Bewegung](/de/design/motion/) — atmen, verblassen, kein Blinken.
|
||||
54
docs/src/content/docs/de/design/palette.mdx
Normal file
54
docs/src/content/docs/de/design/palette.mdx
Normal file
@@ -0,0 +1,54 @@
|
||||
---
|
||||
title: "Palettenfarben"
|
||||
description: "Jede Farbe von Nibiru, direkt aus dem Lotus-Symbol im Firmenlogo entnommen."
|
||||
---
|
||||
|
||||
import Swatch from '../../../../components/Swatch.astro';
|
||||
|
||||
## Die Markenblüten
|
||||
|
||||
Die beiden Farben, um die alles andere kreist, entnommen aus dem Lotus-Symbol.
|
||||
|
||||
<Swatch token="--nibiru-iris" hex="#7c70ab" name="Iris" usage="Primäre Markenviolett — die inneren Blätter des Lotus. Wird für Akzente, Links, Fokusringe und Haarlinien verwendet." />
|
||||
<Swatch token="--nibiru-iris-deep" hex="#5e548c" name="Iris Deep" usage="Tiefere Violette für hervorragende Texte und aktive Zustände." />
|
||||
<Swatch token="--nibiru-iris-soft" hex="#b6adcd" name="Iris Soft" usage="Hellviolette für sanfte Hintergründe und dekorative Ornamente." />
|
||||
<Swatch token="--nibiru-skyfall" hex="#7db7dc" name="Skyfall" usage="Markenblau — die äußeren Blätter. Kühle Akzente, dunkle Modus-Links." />
|
||||
<Swatch token="--nibiru-skyfall-deep" hex="#4a8fb7" name="Skyfall Deep" usage="Link-Hervorhebung, Fokus." />
|
||||
<Swatch token="--nibiru-skyfall-soft" hex="#c2dcec" name="Skyfall Soft" usage="Hellblauer Oberflächenfarbton." />
|
||||
|
||||
## Die Seite
|
||||
|
||||
Es gibt im System **keine rein weiße Farbe**.
|
||||
|
||||
<Swatch token="--nibiru-paper" hex="#f5f1e8" name="Papier" usage="Die Seite. Die dominante Farbe des Designs." />
|
||||
<Swatch token="--nibiru-mist" hex="#faf7f0" name="Nebel" usage="Leichter Papier — erhöhte Oberflächen (Karten, Panels)." />
|
||||
<Swatch token="--nibiru-lavender" hex="#ece6f3" name="Lavendel" usage="Oberflächentönung mit einem violettischen Flüstern — Seitenleisten, Aufrufe." />
|
||||
<Swatch token="--nibiru-lavender-deep" hex="#ddd3eb" name="Tiefes Lavendel" usage="Große Hintergrundzahlen auf der Startseite." />
|
||||
|
||||
## Text
|
||||
|
||||
<Swatch token="--nibiru-ink" hex="#1f1b2e" name="Tinte" usage="Körperlicher Text. Tiefes Indigoblack, nie rein schwarz." />
|
||||
<Swatch token="--nibiru-ink-soft" hex="#4a4258" name="Tinte Weich" usage="Führende Absätze, sekundärer Text." />
|
||||
<Swatch token="--nibiru-ink-faint" hex="#847b94" name="Tinte Leicht" usage="Untertitel, Tabellenbeschriftungen, deaktivierter Zustand." />
|
||||
|
||||
## Eingeschränkte Akzente
|
||||
|
||||
Drei Farben werden **selten** verwendet – jede erhält ihren Platz, indem sie nur in einer Rolle erscheint.
|
||||
|
||||
<Swatch token="--nibiru-aurum" hex="#c9a96e" name="Aurum" usage="Gold leaf — die höchste Betonung. Der Unterstrich hinter dem Wort *Impress* im Splash-Hero. Verwenden Sie einmal pro Bildschirm." />
|
||||
<Swatch token="--nibiru-rose" hex="#d68a8a" name="Rose" usage="Die einzige Warnhinweis. Wird von der Grenze des danger-callout verwendet." />
|
||||
<Swatch token="--nibiru-moss" hex="#94a96e" name="Moss" usage="Der einzige Erfolgs- / gültige Hinweis. Reserviert für Statusindikatoren." />
|
||||
|
||||
## Dunkler Modus
|
||||
|
||||
Wenn der Benutzer den Dunkelmodus umschaltet, wechselt das System zu einer twilight-Palette, behält aber die Blüten unverändert bei.
|
||||
|
||||
<Swatch token="--nibiru-dark-bg" hex="#181428" name="Twilight" usage="Hintergrundfarbe für den Dunkelmodus." />
|
||||
<Swatch token="--nibiru-dark-surface" hex="#1f1933" name="Aubergine" usage="Oberflächenfarbe für Karten und Panels im Dunkelmodus." />
|
||||
|
||||
## Verboten
|
||||
|
||||
- **Keine lila Verläufe auf weißem Hintergrund.** Dieses System lehnt explizit diese künstliche Intelligenz-Asthetik ab.
|
||||
- **Verwenden Sie kein rein schwarzes oder weißes.** Nutzen Sie Tinte und Papier.
|
||||
- **Verdünnen Sie das Gold nicht.** Aurum sollte niemals neben einem anderen Akzent stehen. Maximal ein Goldblattmoment pro Bildschirm.
|
||||
- **Führen Sie keine neuen Akzente ein.** Die Palette ist absichtlich klein.
|
||||
106
docs/src/content/docs/de/design/typography.md
Normal file
106
docs/src/content/docs/de/design/typography.md
Normal file
@@ -0,0 +1,106 @@
|
||||
---
|
||||
title: "Typografie"
|
||||
description: "Eine Variable-Familie wird ernsthaft verwendet – Bricolage Grotesque, Achsen opsz 12–96 und Gewicht 200–800."
|
||||
---
|
||||
|
||||
## Eine Familie, drei Achsen
|
||||
|
||||
Nibiru verwendet **Bricolage Grotesque** (kostenlos, Google Fonts) für jeden Textabschnitt. Der Trick ist, dass es sich um eine **variablen Schriftart** handelt, die zwei sinnvolle Achsen hat:
|
||||
|
||||
- **Optische Größe (`opsz`, 12–96)** — Anzeigegrößen werden zeichnerischer, gestenkt und leicht quadratischer; Textgrößen werden ruhiger und offener.
|
||||
- **Gewicht (`wght`, 200–800)** — gesamter Bereich verfügbar.
|
||||
- **Kursiv** — echtes Kursivschrift, nicht geneigt.
|
||||
|
||||
Also eine einzelne Schriftdatei bietet uns eine Anzeigeschriftart *und* eine Körperschriftart. Es ist keine Notwendigkeit, zusätzliche Schriftarten zu laden.
|
||||
```css
|
||||
font-family: 'Bricolage Grotesque', system-ui, sans-serif;
|
||||
font-variation-settings: 'opsz' 96, 'wght' 600; /* display */
|
||||
font-variation-settings: 'opsz' 14, 'wght' 400; /* body */
|
||||
```
|
||||
Der Code verwendet **JetBrains Mono**, da er leise ausgezeichnet ist und gut zusammenpasst.
|
||||
|
||||
## Variationsvorlagen (die Bausteine)
|
||||
|
||||
| Token | Variation | Use |
|
||||
|---|---|---|
|
||||
| `--nibiru-fv-display-hero` | `'opsz' 96, 'wght' 600` | Hero H1 |
|
||||
| `--nibiru-fv-display-large` | `'opsz' 64, 'wght' 600` | H2 |
|
||||
| `--nibiru-fv-display-medium` | `'opsz' 36, 'wght' 600` | H3 |
|
||||
| `--nibiru-fv-heading-small` | `'opsz' 18, 'wght' 600` | H4 |
|
||||
| `--nibiru-fv-lead` | `'opsz' 24, 'wght' 400` | Lead paragraph |
|
||||
| `--nibiru-fv-body` | `'opsz' 14, 'wght' 400` | Body |
|
||||
| `--nibiru-fv-body-bold` | `'opsz' 14, 'wght' 600` | Strong |
|
||||
| `--nibiru-fv-label` | `'opsz' 12, 'wght' 600` | Labels, eyebrow |
|
||||
|
||||
## Schriftgradskala
|
||||
|
||||
| Token | Größe | Verwendung |
|
||||
|---|---|---|
|
||||
| `--nibiru-text-xs` | 0,72rem | Eyebrow / Labels |
|
||||
| `--nibiru-text-sm` | 0,85rem | Captions |
|
||||
| `--nibiru-text-md` | 0,92rem | UI Steuerelemente, Seitenleiste |
|
||||
| `--nibiru-text-base` | 1,00rem | Haupttext |
|
||||
| `--nibiru-text-lg` | 1,18rem | Leitabsatz |
|
||||
| `--nibiru-text-xl` | 1,45rem | Abschnittseinführungen |
|
||||
| `--nibiru-text-2xl` | 2,00rem | H3 |
|
||||
| `--nibiru-text-3xl` | 2,60rem | H2 |
|
||||
| `--nibiru-text-hero` | clamp(2,6rem, 1,8rem + 4vw, 4,8rem) | H1, Hero |
|
||||
|
||||
## Verfolgung
|
||||
|
||||
Die Verfolgung wird enger, je größer die Größe ist – so bleibt ein Held dicht gefühlt und der Körper offen.
|
||||
|
||||
| Token | Wert | Verwendung |
|
||||
|---|---|---|
|
||||
| `--nibiru-tracking-display` | −0.04em | Hero / H1 |
|
||||
| `--nibiru-tracking-heading` | −0.025em | H2, H3 |
|
||||
| `--nibiru-tracking-body` | −0.005em | Body |
|
||||
| `--nibiru-tracking-label` | 0.10em | Großbuchstaben-Labels |
|
||||
| `--nibiru-tracking-eyebrow` | 0.22em | Hero Eyebrow, Abschnittszahlen |
|
||||
|
||||
## Ein Held, ausgeschrieben
|
||||
```css
|
||||
h1.atelier-hero__title {
|
||||
font-family: var(--nibiru-font-display);
|
||||
font-variation-settings: var(--nibiru-fv-display-hero);
|
||||
font-size: var(--nibiru-text-hero);
|
||||
letter-spacing: var(--nibiru-tracking-display);
|
||||
line-height: 0.96;
|
||||
color: var(--nibiru-ink);
|
||||
max-width: 16ch;
|
||||
}
|
||||
|
||||
h1.atelier-hero__title em {
|
||||
font-style: normal;
|
||||
color: var(--nibiru-iris-deep);
|
||||
position: relative;
|
||||
}
|
||||
h1.atelier-hero__title em::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: auto 0 0.05em 0;
|
||||
height: 0.18em;
|
||||
background: var(--nibiru-aurum);
|
||||
opacity: 0.3;
|
||||
z-index: -1;
|
||||
}
|
||||
```
|
||||
Das ist ein Held. Das kursive Wort erhält eine einzelne Goldbleistiftunterstrichung. Nichts anderes braucht Dekoration.
|
||||
|
||||
## Ein Körper, ausgeschrieben
|
||||
```css
|
||||
body, .prose p {
|
||||
font-family: var(--nibiru-font-text);
|
||||
font-variation-settings: var(--nibiru-fv-body);
|
||||
font-size: var(--nibiru-text-base);
|
||||
line-height: 1.7;
|
||||
letter-spacing: var(--nibiru-tracking-body);
|
||||
color: var(--nibiru-ink);
|
||||
}
|
||||
```
|
||||
## Verboten
|
||||
|
||||
- Verwenden Sie nicht Bricolage Grotesque für Hero und Body im gleichen OPSZ – das verschwendet seine variable Achse.
|
||||
- Verwenden Sie keine Fettschrift für Text unter `wght: 600`. Unter 600 wird es nicht als fett gelesen.
|
||||
- Verwenden Sie keine positive Tracking für Displaytext. Verengen Sie es, wenn die Größe zunimmt.
|
||||
- Verwenden Sie nicht Bricolage Italic für ganze Sätze – verwenden Sie es für einzelne Wörter innerhalb eines Überschriften. Der "Impress." Trick auf der Splash Page ist das Beispiel.
|
||||
91
docs/src/content/docs/de/index.mdx
Normal file
91
docs/src/content/docs/de/index.mdx
Normal file
@@ -0,0 +1,91 @@
|
||||
---
|
||||
title: Nibiru
|
||||
description: Geboren unter den alten Göttern, gebaut für moderne Entwickler. Ein modulares MMVC-PHP-Framework für rapides Prototyping.
|
||||
template: splash
|
||||
hero:
|
||||
eyebrow: Modulares MMVC-PHP-Framework
|
||||
title: |
|
||||
Baue mit der<br/>Geschwindigkeit der Sterne.
|
||||
tagline: |
|
||||
<strong>Nibiru</strong> ist ein modulares MMVC-PHP-Framework für rapides Prototyping — ein Router, eine Smarty-basierte View-Schicht, Multi-Datenbank-Unterstützung (MySQL · PostgreSQL · ODBC · PDO), ein flüssiger Form-Builder und ein CLI, das Module, Controller und Migrationen in Sekunden generiert.
|
||||
actions:
|
||||
- text: Reise beginnen
|
||||
link: /de/start/what-is-nibiru/
|
||||
icon: right-arrow
|
||||
variant: primary
|
||||
- text: Auf GitHub ansehen
|
||||
link: https://github.com/alllinux/Nibiru
|
||||
icon: external
|
||||
variant: minimal
|
||||
---
|
||||
|
||||
import { Card, CardGrid, LinkCard } from '@astrojs/starlight/components';
|
||||
|
||||
## Eine Konstellation von Funktionen
|
||||
|
||||
<CardGrid stagger>
|
||||
<Card title="MMVC-Architektur" icon="puzzle">
|
||||
Module umschließen MVC mit Traits, Plugins, Interfaces und Settings. Das zweite **M** ist lose gekoppelt — und besitzt das Observer-Muster (`SplSubject`) ab Werk.
|
||||
</Card>
|
||||
<Card title="Multi-Datenbank-Kern" icon="seti:db">
|
||||
Fünf Treiber im Orbit: `mysql`, `pdo`, `postgres` (ODBC), `psql` (libpq) und `postgresql`. Wechsel durch Ändern eines INI-Schlüssels.
|
||||
</Card>
|
||||
<Card title="Smarty Views" icon="document">
|
||||
Template-getriebene Views, ein globaler `View::assign()`-Helfer und ein heißer `templates_c`-Cache. Plus geteilte Partials, Navigations-Includes und Pagination-Templates.
|
||||
</Card>
|
||||
<Card title="Flüssiger Form-Builder" icon="pencil">
|
||||
28+ Feldtypen — Text, Passwort, Switch, Color, Range, File-Upload — komponiert über statische `Form::add…`-Aufrufe und als ein einziger HTML-String gerendert.
|
||||
</Card>
|
||||
<Card title="Echtes CLI-Tool" icon="seti:powershell">
|
||||
`./nibiru` generiert Module, Controller, Plugins, Migrationen und CMS-Seiten. Es führt Migrationen gegen `local`, `staging` oder `production` aus.
|
||||
</Card>
|
||||
<Card title="KI-nativ, demnächst" icon="rocket">
|
||||
Nibiru wird das erste PHP-Framework mit einem eingebauten <a href="/de/ai/oracle/">RAG-basierten Orakel</a>, das auf seinem eigenen Wissen trainiert ist — und einem veröffentlichten Korpus für künftige Fine-Tunes.
|
||||
</Card>
|
||||
</CardGrid>
|
||||
|
||||
## Schnellstart
|
||||
|
||||
```bash
|
||||
# Klonen
|
||||
git clone https://github.com/alllinux/Nibiru meine-app && cd meine-app
|
||||
|
||||
# PHP-Abhängigkeiten installieren (Smarty, PHPMailer, Guzzle, …)
|
||||
composer install
|
||||
|
||||
# Berechtigungen + Ordner-Bootstrap
|
||||
./nibiru -s
|
||||
|
||||
# Erste Migration ausführen
|
||||
./nibiru -mi local
|
||||
|
||||
# Controller generieren
|
||||
./nibiru -c products
|
||||
```
|
||||
|
||||
```php
|
||||
// application/controller/productsController.php
|
||||
namespace Nibiru;
|
||||
use Nibiru\Adapter\Controller;
|
||||
|
||||
class productsController extends Controller {
|
||||
public function pageAction() {
|
||||
View::assign([
|
||||
'title' => 'Produkte — Nibiru',
|
||||
'products' => [['id' => 1, 'name' => 'Marduk-Goldbeschichtung']],
|
||||
]);
|
||||
}
|
||||
public function navigationAction() {
|
||||
JsonNavigation::getInstance()->loadJsonNavigationArray();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Wohin als Nächstes?
|
||||
|
||||
<CardGrid>
|
||||
<LinkCard title="Was ist Nibiru?" href="/de/start/what-is-nibiru/" description="Die 90-Sekunden-Tour: MMVC, der Dispatcher, der Request-Lifecycle." />
|
||||
<LinkCard title="Architektur" href="/core/architecture/" description="Wie Module, Controller, Views und das Registry einander umkreisen." />
|
||||
<LinkCard title="Showcase" href="/showcase/projects/" description="Echte Apps in Produktion auf Nibiru — Rechnungswesen, E-Commerce, industrielles PIM." />
|
||||
<LinkCard title="Frag das Orakel" href="/de/ai/oracle/" description="Öffne den schwebenden Chat. Das Orakel ist auf genau dieser Dokumentation geerdet." />
|
||||
</CardGrid>
|
||||
146
docs/src/content/docs/de/showcase/patterns.md
Normal file
146
docs/src/content/docs/de/showcase/patterns.md
Normal file
@@ -0,0 +1,146 @@
|
||||
---
|
||||
title: "Muster aus der Produktion"
|
||||
description: "Konkrete Muster, die in den Produktionsanwendungen von Nibiru verwendet werden – kopierbar und bereit zum Einfügen."
|
||||
---
|
||||
|
||||
Sieben Muster, die sich wiederholt finden in den Produktionsanwendungen von Nibiru. Jedes ist klein, bereit zum Kopieren und Einfügen und basiert auf einer echten Codebasis.
|
||||
|
||||
## 1. Dünnere Controller → Delegierung an Modul-Plugins
|
||||
|
||||
Behalten Sie die Controller klein. Verschieben Sie die Logik in Modul-Plugins.
|
||||
```php
|
||||
// thin
|
||||
class erpController extends Controller {
|
||||
public function syncAction(): void {
|
||||
View::forwardToJsonHeader();
|
||||
$result = \Nibiru\Module\Erp\Plugin\Sync::run();
|
||||
View::assign(['data' => $result]);
|
||||
}
|
||||
}
|
||||
``````php
|
||||
// fat
|
||||
class Sync extends Erp {
|
||||
public static function run(): array {
|
||||
$svc = AlphaplanSyncService::getInstance();
|
||||
try {
|
||||
return ['success' => true, 'changes' => $svc->syncAbDocuments()];
|
||||
} catch (\Throwable $e) {
|
||||
return ['success' => false, 'error' => $e->getMessage()];
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
Der Controller kann in 5 Sekunden überprüft werden; das Plugin ist testbar.
|
||||
|
||||
## 2. CMS als Inhaltsquelle
|
||||
|
||||
Trennen Sie Text und Layout voneinander. Redakteure aktualisieren den Kopf über die Benutzeroberfläche des CMS-Moduls; Vorlagen bleiben Eigentum der Entwickler.
|
||||
```php
|
||||
public function pageAction() {
|
||||
$path = $this->getController() . '/' . $this->getRequest('_action', 'page');
|
||||
foreach (Cms::init($this->getController())
|
||||
->loadCmsTemplateTextsByControllerPath($path, $this->language)
|
||||
as $t)
|
||||
{
|
||||
View::assign([
|
||||
$t['cms_template_texts_text_identifier']
|
||||
=> $t['cms_template_texts_text_content']
|
||||
]);
|
||||
}
|
||||
}
|
||||
```
|
||||
Vorlagen verweisen auf die Bezeichner, als wären sie normale Variablen:
|
||||
```smarty
|
||||
<h1>{$hero_title}</h1>
|
||||
<p>{$hero_intro}</p>
|
||||
```
|
||||
## 3. Beobachtergesteuerte Analyse
|
||||
|
||||
Mehrere Tracker ohne Controller-Kopplung.
|
||||
```php
|
||||
$analytics = new Analytics();
|
||||
$analytics->attach(new Plugin\Matomo());
|
||||
$analytics->attach(new Plugin\Plausible());
|
||||
$analytics->trackPageView(); // calls notify() internally
|
||||
```
|
||||
Jeder Beobachter ruft nur die Felder ab, die er interessiert sind. Hinzufügen eines Trackers ist eine Änderung in einer Zeile.
|
||||
|
||||
## 4. Mehrfach-Navigationskomposition
|
||||
|
||||
Seiten mit mehreren benannten Navigationsarrays anstelle einer einheitlichen Struktur erstellen.
|
||||
```php
|
||||
public function navigationAction() {
|
||||
foreach (['head', 'main', 'social', 'footer'] as $name) {
|
||||
JsonNavigation::getInstance()->loadJsonNavigationArray($name);
|
||||
}
|
||||
}
|
||||
``````smarty
|
||||
<header>{include file="navigation.tpl" array=$head}</header>
|
||||
<aside>{include file="navigation.tpl" array=$main}</aside>
|
||||
<footer>{include file="navigation.tpl" array=$footer}</footer>
|
||||
```
|
||||
Jede JSON-Datei ist klein, eingeschränkt, leicht bearbeitbar und ohne Konflikte in Pull Requests.
|
||||
|
||||
## 5. JSON-Endpunkte mit `forwardToJsonHeader`
|
||||
|
||||
Standardvertrag für AJAX:
|
||||
```php
|
||||
public function searchAction() {
|
||||
View::forwardToJsonHeader();
|
||||
$q = trim($_REQUEST['q'] ?? '');
|
||||
if (strlen($q) < 2) {
|
||||
View::assign(['data' => ['results' => []]]);
|
||||
return;
|
||||
}
|
||||
View::assign(['data' => [
|
||||
'results' => MachineryScout::index()->search($q),
|
||||
]]);
|
||||
}
|
||||
```
|
||||
Kopfzeilen werden automatisch gesetzt; keine manuelle `header('Content-Type: application/json')`.
|
||||
|
||||
## 6. Vielschrittiger Workflow über Aktionen
|
||||
|
||||
Zustandsmaschinen abgebildet auf Controlleraktionen:
|
||||
```php
|
||||
class quotesController extends Controller {
|
||||
public function pageAction() { /* list view */ }
|
||||
public function detailAction() { /* one quote */ }
|
||||
public function acceptAction() { /* state transition: open → accepted */ }
|
||||
public function rejectAction() { /* state transition: open → rejected */ }
|
||||
public function archiveAction() { /* state transition: any → archived */ }
|
||||
}
|
||||
```
|
||||
`/quotes/accept/42` führt `acceptAction()` mit `$_REQUEST['id'] = 42` aus. Jede Übergang ist eine kleine Aktion; Persistenz und Benachrichtigungen erfolgen durch ein `QuotesService`-Plugin.
|
||||
|
||||
## 7. Schema-first Modelle mit einer benutzerdefinierten Methode pro Absicht
|
||||
|
||||
Generieren Sie das Modell aus dem Schema und fügen Sie dann mit Absicht benannte Methoden hinzu, die Ihre Abfragen umschließen:
|
||||
```php
|
||||
class users extends Db
|
||||
{
|
||||
const TABLE = ['table' => 'users', 'field' => [/* … */]];
|
||||
public function __construct() { self::initTable(self::TABLE); }
|
||||
|
||||
public function findByLogin(string $login): ?array {
|
||||
return Pdo::fetchRow('SELECT * FROM users WHERE user_login = :l',
|
||||
[':l' => $login]) ?: null;
|
||||
}
|
||||
|
||||
public function activeStandardUsers(): array {
|
||||
return Pdo::fetchAll(
|
||||
'SELECT * FROM users WHERE user_account_active = 1 AND user_role = :r',
|
||||
[':r' => 'standard']
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
Zukünftiges Sie, das den Aufrufort geht, sieht `findByLogin($login)` – die Absicht – nicht rohe SQL.
|
||||
|
||||
## Muster zu vermeiden
|
||||
|
||||
- **Statische-Puffer-Leckage in Formularen.** Verwenden Sie immer `Form::create()` vor dem Aufbau.
|
||||
- **Logik in `navigationAction()`.** Sie wird bei jeder Anfrage ausgeführt, einschließlich JSON-Endpunkten.
|
||||
- **Massenverwendung von `View::assign()`** ohne strukturiertes Array. Verwenden Sie `View::assign(['…'])` einmal.
|
||||
- **Benutzerdefinierte Routen für was die SEO-URL bereits tut.** `/products/<slug>/<id>` ist kostenlos.
|
||||
- **Bearbeiten generierter Modelle.** Sie werden überschrieben. Benutzerdefinierte Methoden → Kindklasse oder `database.overwrite = false`.
|
||||
133
docs/src/content/docs/de/showcase/projects.md
Normal file
133
docs/src/content/docs/de/showcase/projects.md
Normal file
@@ -0,0 +1,133 @@
|
||||
---
|
||||
title: "Im Produktionsumfeld"
|
||||
description: "Echte Nibiru-Anwendungen bringen echtes Einkommen. Maschinen Stockert verkauft industrielle Maschinen in 12 Ländern auf diesem Framework."
|
||||
---
|
||||
|
||||
Ein Framework, das sich lohnt zu verwenden, bringt Dinge mit. Der Flaggschiff-Nibiru-Bereitstellung ist die **Maschinen Stockert**-Gruppe – eine Paar von Repositorys, die einen der größten industriellen-Geräte-E-Commerce-Plattformen Österreichs betreiben.
|
||||
|
||||
Die beiden Repositories teilen Module und eine Datenbank; sie teilen die Verantwortung nach Publikum.
|
||||
|
||||
---
|
||||
|
||||
<div class="showcase-plate">
|
||||
<img src="/img/showcase-tpms.png" alt="Ein industrieller CNC-Maschinenaufbau auf einem Fabrikflur zur goldenen Stunde." />
|
||||
<div class="showcase-plate__body">
|
||||
<p class="showcase-plate__meta">10 Steuerungen · 18 Module · 150 Vorlagen · 36.289 Zeilen PHP</p>
|
||||
<h3 class="showcase-plate__title">maschinen-stockert.de — öffentlicher Katalog</h3>
|
||||
<p class="showcase-plate__desc">Die Verkaufsseite. Durchsuchen und suchen Sie gebrauchte industrielle Maschinen, zeigen Sie reiche Detailseiten mit SEO-freundlichen Slug an, melden Sie sich für das jährliche Hausmesse-Messeveranstaltung an, fordern Sie Angebote an.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
## maschinen-stockert.de — öffentlicher Katalog
|
||||
|
||||
Die öffentliche Seite: Dies ist der Ort, an dem industrielle Käufer landen, suchen und umwandeln.
|
||||
|
||||
### Was darauf enthalten ist
|
||||
|
||||
- **Multilingual content**, bei dem jeder sichtbare String aus der `cms_template_texts` Datenbank abgerufen wird, die durch `<controller>/<action>` + Sprache indiziert ist. Redakteure aktualisieren den Text im Admin-Bereich ohne einen Deployment.
|
||||
- **Elasticsearch-gestützte maschinelles Suchen**, mit typbewusster Filterung — Abmessungen werden aus `"2500 × 1200 mm"` Zeichenketten in numerische Bereiche analysiert, sodass Käufer nach Größe suchen können.
|
||||
- **SEO-freundliche URLs** — `/maschine/drehmaschine-2500/42` — generiert aus dem Maschinenname mit Normalisierung der deutschen Umlaute (`ä → ae`, `ß → ss`). Die numerische ID steht immer am Ende, sodass der Router einen veralteten Slug auflösen kann.
|
||||
- **Yumpu PDF Flipbooks** für herunterladbare Kataloge.
|
||||
- **Hausmesse Modul** für die Anmeldung auf Messestandsmitte mit rollenbasiertem Zugriff.
|
||||
|
||||
### Eine repräsentative Aktion
|
||||
```php
|
||||
// application/controller/maschineController.php
|
||||
public function detailAction()
|
||||
{
|
||||
$machineId = $this->getRequest('id', true);
|
||||
if (!$machineId) {
|
||||
http_response_code(404);
|
||||
return;
|
||||
}
|
||||
|
||||
$controllerPath = $this->getController() . '/detail';
|
||||
$cmsTemplateTexts = Cms::init($this->getController())
|
||||
->loadCmsTemplateTextsByControllerPath($controllerPath, $this->language);
|
||||
|
||||
foreach ($cmsTemplateTexts as $t) {
|
||||
View::assign([
|
||||
$t['cms_template_texts_text_identifier']
|
||||
=> $t['cms_template_texts_text_content']
|
||||
]);
|
||||
}
|
||||
|
||||
try {
|
||||
$machine = Machine::init()->getMachine((int) $machineId);
|
||||
} catch (\Throwable $e) {
|
||||
$machine = null; // DB blip → page still renders with fallback.
|
||||
}
|
||||
|
||||
$machineName = $machine['ms_machines_name'] ?? "Maschine #$machineId";
|
||||
$protocol = (($_SERVER['HTTPS'] ?? '') === 'on') ? 'https' : 'http';
|
||||
|
||||
View::assign([
|
||||
'machine' => $machine,
|
||||
'pageTitle' => "$machineName - Maschinen Stockert",
|
||||
'metaDescription'=> "Details und Spezifikationen für $machineName",
|
||||
'canonicalUrl' => $protocol . '://' . $_SERVER['HTTP_HOST']
|
||||
. self::generateMachineSeoUrl($machineId, $machineName),
|
||||
]);
|
||||
}
|
||||
```
|
||||
50 Zeilen, kein Dependency Injection Container, kein Validierungs-Pipeline, kein Middleware Stack. Lädt zuerst die von der CMS verwaltete Kopie (damit eine Datenbankausfall auf den Maschinen die Seite nicht zerstört), zieht die Maschine mit einem sanften Fallback, generiert SEO-Metadaten immer. Die gesamte Detailseite wird in zwei Datenbank-Round-Trips gerendert.
|
||||
|
||||
---
|
||||
|
||||
<div class="showcase-plate">
|
||||
<img src="/img/showcase-thorax.png" alt="Eine Wand aus gut benutzten technischen Lederbüchern, eines davon im Weichfokus geöffnet." />
|
||||
<div class="showcase-plate__body">
|
||||
<p class="showcase-plate__meta">36 Controller · 18 Module · 348 Vorlagen · 161 SQL-Migrationen · 37.369 Zeilen PHP</p>
|
||||
<h3 class="showcase-plate__title">data.maschinen-stockert.de — Admin & API</h3>
|
||||
<p class="showcase-plate__desc">Das interne Cockpit. Vertriebsmitarbeiter verwalten den Bestand, Inhalte, Aufträge, Teamseiten und Messeanmeldungen. Entwickler und Integrationen rufen REST-APIs auf, um Maschinen zu suchen, Hersteller zu synchronisieren, PDFs zu generieren und Ollama für künstliche Intelligenz-basierte Maschinendescriptions abzurufen.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
## data.maschinen-stockert.de — Admin und API
|
||||
|
||||
Die gleichen Module, dreimal so viele Controller und Templates – weil Admin-Benutzeroberflächen und APIs viele Einstiegspunkte benötigen.
|
||||
|
||||
### Was es macht
|
||||
|
||||
- **Page-tree CMS**: Redakteure erstellen Seiten aus einer Smarty-Vorlage; das `Parser`-Plugin durchsucht die Platzhalter `{$identifier}` in der Vorlage und generiert automatisch die Bearbeitungsformularschnittstelle für den Admin-Bereich. Die Vorlage *ist* die Formulierungsspezifikation.
|
||||
- **Rollenbasierte ACL**: jeder Konstruktor des Admin-Controllers ruft `$this->user = new User(); $this->acl = new Acl(); $this->acl->init(); $this->user->validate();` – drei Zeilen, fertig. Vertrieb hat nur Lesezugriff auf den Bestand; Administratoren können bearbeiten; Partner sehen nur ihre zugewiesenen Einträge.
|
||||
- **Public-API-Zulassungsliste**: `apiController` erlaubt maschinenbasierte Suchen, Kategorieabrufe, Teaminformationen und Ollama-AI-Aufrufe ohne Authentifizierung, erfordert dann jedoch Authentifizierung für alles andere. Die Zulassungsliste befindet sich direkt im Konstruktor – keine Probleme mit der Reihenfolge des Middlewares.
|
||||
- **Machineryscout Indexer**: das schwerste Modul. Ein 2.200-Zeilen-Trait, das Maschinen + Attribute + Bilder + Dokumente aus MySQL über `JSON_ARRAYAGG` zieht, normalisiert die Typen (`"2500 x 1200"` → `dimension_width: 2500.0, dimension_height: 1200.0`), ersetzt Platzhalter für fehlende Bilder und schreibt Zeilen in Elasticsearch.
|
||||
|
||||
### Der Parser-Muster — automatische Generierung von Editor-Benutzeroberflächen aus einer Vorlage
|
||||
```php
|
||||
// application/controller/adminController.php
|
||||
$parser = Parser::init();
|
||||
$cmsEditable = $parser->parseSmartyTemplateByTemplateId($templateId);
|
||||
View::assign([
|
||||
'cmsEditable' => $cmsEditable,
|
||||
'cmsTemplateTextForm'=> Cms::textsEditingForm('/admin/texts/create/text/new'),
|
||||
]);
|
||||
```
|
||||
Fügen Sie eine neue Smarty-Vorlage ins System ein, der Editor weiß sofort, welche Platzhalter bearbeitet werden können. Es gibt keinen Schritt zur "Registrierung der Formularfelder".
|
||||
|
||||
---
|
||||
|
||||
## Was ist tatsächlich besonderes, zusammengefasst
|
||||
|
||||
Die fünf Unterschiedsmaker unten stammen aus den darüberliegenden Codebasen. Jeder verweist auf seine Beweise auf der Seite [Warum Nibiru](/de/warum-nibiru/).
|
||||
|
||||
| | Was Nibiru macht | Was Laravel/Symfony macht |
|
||||
|---|---|---|
|
||||
| Seitenkopie | Aus der Datenbank per Anfrage durch `<controller>/<action>` geladen, von einem Editor verwaltet. | Hardschrieben in Blade / Übersetzungs-JSON; für Änderungen bereitstellen. |
|
||||
| Modulkomposition | 13 Traits pro Modul, keine DI. | Dienstanbieter + IoC-Container. |
|
||||
| ORM | Direkte SQL, MySQL `JSON_ARRAYAGG`, `Pdo::fetchAll`. | Eloquent / Doctrine-Entitäten + Lazy-Loading-Proxys. |
|
||||
| Authentifizierung | 3 Zeilen im Controller-Konstruktor. | Middleware-Stack + Policy-Klassen + Gates. |
|
||||
| Ereignisse | `SplSubject` + `SplObserver` aus der PHP Standardbibliothek. | Benutzerdefinierter Event-Dispatcher + Listener-Registry + Warteschlange. |
|
||||
|
||||
Lesen Sie die [vollständige Ausbreakdown mit Codeverweisen →](/de/warum-nibiru/)
|
||||
|
||||
---
|
||||
|
||||
## Was dazu notwendig war
|
||||
|
||||
Maschinen Stockert ist eine echte, einkommensgenerierende Website, die Maschinen in 12 Ländern verkauft. Sie hat **161 timestamped SQL-Migrationen** ohne Migrationsframework in die Produktion übertragen, **74.000 Zeilen PHP über zwei Repositories** ohne Dienstcontainer und **18 Module**, die mit Traits anstelle von Vererbung zusammengesetzt werden. Das Team, das es erstellt und betreut, ist klein.
|
||||
|
||||
Das ist die Beweisnachweisung.
|
||||
|
||||
Wenn Sie mit einer kleinen Team und einem engen Budget in die Produktion gehen, bei der Abstimmungen und Ausfallzeiten echtes Geld kosten — das ist der Nibiru-Sweet Spot. Lesen Sie den [Schnellstart](/en/start/quick-start/), dann kommen Sie zurück.
|
||||
119
docs/src/content/docs/de/start/deployment.md
Normal file
119
docs/src/content/docs/de/start/deployment.md
Normal file
@@ -0,0 +1,119 @@
|
||||
---
|
||||
title: "Die Dokumentationssite bereitstellen"
|
||||
description: "Produktionsbereitstellung von nibiru-framework.com mit jwilder/nginx-proxy und Ihrem eigenen Ollama auf neuronetz.ai."
|
||||
---
|
||||
|
||||
Diese Seite dokumentiert, wie die Dokumentationssite in der Produktionsumgebung bereitgestellt wird. Die Einrichtung verwendet **jwilder/nginx-proxy** für automatische Docker-Containerrouting, **letsencrypt-nginx-proxy-companion** für HTTPS und **Ihre eigene Ollama unter api.neuronetz.ai** für den Oracle-Backend — keine bezahlten LLM-API-Schlüssel erforderlich.
|
||||
|
||||
## Topologie
|
||||
```
|
||||
Internet
|
||||
│
|
||||
▼
|
||||
┌──────────────────────┐
|
||||
│ jwilder/nginx-proxy │ ← reverse proxy on :80 / :443
|
||||
│ (network: nginx-proxy)│ terminates TLS
|
||||
└──────────┬───────────┘
|
||||
│ http://nibiru-docs:4321
|
||||
▼
|
||||
┌──────────────────────┐ ┌──────────────────────┐
|
||||
│ nibiru-docs │ ──────▶ │ api.neuronetz.ai │
|
||||
│ Astro Node SSR :4321 │ HTTPS │ Ollama (5× GPU) │
|
||||
│ Oracle endpoint │ │ qwen2.5-coder:14b │
|
||||
└──────────────────────┘ │ nomic-embed-text │
|
||||
└──────────────────────┘
|
||||
```
|
||||
## Voraussetzungen auf dem Host
|
||||
```bash
|
||||
# 1) Create the shared external network (one time)
|
||||
docker network create nginx-proxy
|
||||
|
||||
# 2) Run nginx-proxy + acme-companion (one time)
|
||||
# See https://github.com/nginx-proxy/nginx-proxy for the canonical compose.
|
||||
|
||||
# 3) Pull the Oracle's models on neuronetz.ai (one time)
|
||||
curl https://api.neuronetz.ai/api/pull -d '{"name":"qwen2.5-coder:14b"}'
|
||||
curl https://api.neuronetz.ai/api/pull -d '{"name":"nomic-embed-text"}'
|
||||
```
|
||||
## Dateien in `docs/`
|
||||
|
||||
| Datei | Zweck |
|
||||
|---|---|
|
||||
| `Dockerfile` | Mehrstufiger Build: erstellt Oracle-Index gegen neuronetz.ai, baut Astro, entfernt Entwicklungsabhängigkeiten. |
|
||||
| `docker-compose.yml` | Produktion — `VIRTUAL_HOST=nibiru-framework.com`, verbunden mit dem Netzwerk `nginx-proxy`. |
|
||||
| `docker-compose.local.yml` | Lokale-Test-Überschreibung — macht Port `4321:4321` verfügbar, entfernt Umgebungsvariablen von nginx-proxy. |
|
||||
| `.dockerignore` | Verhindert, dass `node_modules`, `.git` usw. in den Build-Kontext aufgenommen werden. |
|
||||
| `.env.example` | Vorlage — standardmäßig verwendet Ollama auf neuronetz.ai, keine API-Schlüssel erforderlich. |
|
||||
|
||||
## Befehl ausführen
|
||||
```bash
|
||||
cd docs
|
||||
cp .env.example .env
|
||||
# defaults are fine for production unless you want to override the Ollama URL or models
|
||||
|
||||
docker compose up -d --build
|
||||
```
|
||||
Der erste Build führt `build-oracle-index.mjs` aus, um die Dokumentation in Ihr Ollama einzubetten. Nachfolgende Neuaufbauten sind schnell – Docker speichert den Abhängigkeitslayer im Cache; nur geänderte Blöcke müssen erneut eingebettet werden.
|
||||
|
||||
Nach etwa 30 Sekunden erkennt jwilder/nginx-proxy den neuen Container und fordert ein Let's Encrypt-Zertifikat an. Anschließend leitet es `https://nibiru-framework.com` zu `:4321` weiter.
|
||||
|
||||
## Überprüfen
|
||||
```bash
|
||||
curl -s https://nibiru-framework.com/api/oracle | jq
|
||||
# {
|
||||
# "status": "ok",
|
||||
# "llm": { "provider": "ollama", "model": "qwen2.5-coder:14b", … },
|
||||
# "embed": { "provider": "ollama", "model": "nomic-embed-text", … },
|
||||
# "index": { "present": true, "chunks": 177, … }
|
||||
# }
|
||||
```
|
||||
Oder öffnen Sie die Seite im Browser, klicken Sie auf den gelben Oracle-Launcher und stellen Sie eine Frage.
|
||||
|
||||
## Aktualisierung nach einer Dokumentenänderung
|
||||
```bash
|
||||
git pull
|
||||
docker compose up -d --build
|
||||
```
|
||||
Der Build führt den Oracle-Index erneut gegen den neuesten Inhalt aus; der neue Container startet auf `:4321`; jwilder wechselt den Upstream; der alte Container wird gestoppt – kein auffälliger Ausfallzeitraum.
|
||||
|
||||
## Umgebungsvariablen
|
||||
|
||||
| Var | Default | Used at | Purpose |
|
||||
|---|---|---|---|
|
||||
| `LLM_PROVIDER` | `ollama` | runtime | `ollama` (default) oder `anthropic`. |
|
||||
| `OLLAMA_BASE_URL` | `https://api.neuronetz.ai` | build + runtime | Wo Ollama erreichbar ist. |
|
||||
| `OLLAMA_CHAT_MODEL` | `qwen2.5-coder:14b` | runtime | Chat-Vervollständigungsmodell. |
|
||||
| `OLLAMA_EMBED_MODEL` | `nomic-embed-text` | build + runtime | Einbettungsmodell. |
|
||||
| `EMBED_PROVIDER` | `ollama` | build + runtime | `ollama` oder `openai`. |
|
||||
| `ANTHROPIC_API_KEY` | — | runtime | Nur verwendet, wenn `LLM_PROVIDER=anthropic`. |
|
||||
| `ANTHROPIC_MODEL` | `claude-haiku-4-5-20251001` | runtime | Claude-Modell überschreiben. |
|
||||
| `OPENAI_API_KEY` | — | build + runtime | Nur verwendet, wenn `EMBED_PROVIDER=openai`. |
|
||||
| `ORACLE_TOP_K` | `6` | runtime | Chunks pro Oracle-Antwort eingefügt. |
|
||||
| `LETSENCRYPT_EMAIL` | `stephan.kasdorf@bittomine.com` | letsencrypt | Wo Let's Encrypt Ablaufbenachrichtigungen sendet. |
|
||||
| `VIRTUAL_HOST` | `nibiru-framework.com,www.nibiru-framework.com` | nginx-proxy | In Compose setzen. |
|
||||
|
||||
## Problembehandlung
|
||||
|
||||
**`502 Bad Gateway`.** Der Upstream-Container konnte nicht gestartet werden. Überprüfen Sie `docker logs nibiru-docs` – wahrscheinlich fehlt ein Build-Artefakt in `dist/server/entry.mjs`.
|
||||
|
||||
**Zertifikat nicht ausgestellt.** Let's Encrypt begrenzt die Anzahl der Ausgaben sehr restriktiv. Überprüfen Sie `docker logs letsencrypt-nginx-proxy-companion`, um den Grund zu ermitteln.
|
||||
|
||||
**Oracle antwortet ohne Zitate.** Der Einbettungsindex ist leer. Entweder wurde `nomic-embed-text` nicht auf Ollama gezogen, oder der Build konnte nicht zu neuronetz.ai erreichen. Laden Sie das Modell erneut herunter:```bash
|
||||
curl https://api.neuronetz.ai/api/pull -d '{"name":"nomic-embed-text"}'
|
||||
docker compose up -d --build
|
||||
```
|
||||
**Oracle gibt "der Oracle konnte nicht antworten".** Überprüfen Sie, ob das Chat-Modell abgerufen wurde:```bash
|
||||
curl https://api.neuronetz.ai/api/tags | jq '.models[].name'
|
||||
```
|
||||
**Wollen Sie zu Claude zurückfallen?** Legen Sie `LLM_PROVIDER=anthropic` und `ANTHROPIC_API_KEY` in `.env` fest, dann führen Sie `docker compose up -d` aus.
|
||||
|
||||
## Ressourcennutzung
|
||||
|
||||
| | Leerlauf | Unter Oracle-Last |
|
||||
|---|---|---|
|
||||
| RAM | ca. 120 MB | ca. 200 MB |
|
||||
| CPU | < 0,5% | ca. 5% |
|
||||
| Netzwerk | minimal | ein HTTPS-Roundtrip pro Frage |
|
||||
| Festplatte | ca. 60 MB Bild + ca. 5 MB Index | + Korpus-Exporte |
|
||||
|
||||
Ein Droplet mit 1 GB Speicher und 1 vCPU verarbeitet die Dokumentationssite problemlos zusammen mit anderen Diensten. Die intensiven Berechnungen (LLM-Inferenz) erfolgen in Ihrem GPU-Cluster, nicht im Dokumentationscontainer.
|
||||
92
docs/src/content/docs/de/start/installation.md
Normal file
92
docs/src/content/docs/de/start/installation.md
Normal file
@@ -0,0 +1,92 @@
|
||||
---
|
||||
title: "Installation"
|
||||
description: "Klonen Sie Nibiru, installieren Sie Abhängigkeiten, setzen Sie Berechtigungen und führen Sie Ihre erste Migration aus."
|
||||
---
|
||||
|
||||
## Anforderungen
|
||||
|
||||
- **PHP** ≥ 8.2 mit diesen Erweiterungen: `pdo`, `gd`, `memcached`, `curl`.
|
||||
- **Composer** für PHP-Abhängigkeiten.
|
||||
- **Eine Datenbank**: MariaDB / MySQL ≥ 10.4 oder PostgreSQL ≥ 13. ODBC, wenn Sie eine nicht-native Quelle verbinden.
|
||||
- **Smarty** (installiert über Composer).
|
||||
- Ein Webserver mit **mod_rewrite** (Apache) oder Äquivalent (`vhost.conf` ist für nginx-artige DocumentRoots enthalten).
|
||||
|
||||
## Klonen und Installieren
|
||||
```bash
|
||||
git clone https://github.com/alllinux/Nibiru my-app
|
||||
cd my-app
|
||||
composer install
|
||||
```
|
||||
Composer installiert in `core/l/` (Nibiru verwendet ein ungewöhnliches `vendor-dir`, um sämtlichen Framework-Code unter `core/` zu halten).
|
||||
|
||||
## Konfigurieren
|
||||
|
||||
Kopieren Sie die Beispiel-INI-Datei und bearbeiten Sie Ihre Umgebung:
|
||||
```bash
|
||||
cp application/settings/config/settings.development.ini.example \
|
||||
application/settings/config/settings.development.ini
|
||||
```
|
||||
Die minimalen Abschnitte, die Sie einrichten müssen:
|
||||
```ini
|
||||
[ENGINE]
|
||||
templates = "/../../application/view/templates/"
|
||||
templates_c = "/../../application/view/templates_c/"
|
||||
cache = "/../../application/view/cache/"
|
||||
caching = false
|
||||
debug = true
|
||||
error.controller = "error"
|
||||
|
||||
[SETTINGS]
|
||||
page.url = "https://my-app.local"
|
||||
navigation = "/../../application/settings/config/navigation/main.json"
|
||||
modules.path = "/../../application/module/"
|
||||
entries.per.page = 25
|
||||
smarty.css[] = "/public/css/app.css"
|
||||
smarty.js[] = "/public/js/app.js"
|
||||
timezone = "Europe/Vienna"
|
||||
|
||||
[DATABASE]
|
||||
driver = "pdo" ; one of: mysql, pdo, postgres, psql, postgresql
|
||||
hostname = "localhost"
|
||||
port = 3306
|
||||
username = "nibiru"
|
||||
password = "secret"
|
||||
basename = "nibiru_dev"
|
||||
encoding = "utf8mb4"
|
||||
is.active = true
|
||||
|
||||
[SECURITY]
|
||||
password_hash = "change-me-at-once"
|
||||
|
||||
[GENERATOR]
|
||||
database = true ; auto-generate models from DB tables
|
||||
```
|
||||
`APPLICATION_ENV` wählt aus, welche Datei geladen wird — standardmäßig `settings.development.ini`.
|
||||
```bash
|
||||
export APPLICATION_ENV=production # picks settings.production.ini
|
||||
```
|
||||
## Bootstrap-Ordner und Berechtigungen
|
||||
```bash
|
||||
./nibiru -s
|
||||
```
|
||||
Dies erstellt/berechtigt `/application/view/templates_c/`, `/application/view/cache/` und Protokolldirektorien usw.
|
||||
|
||||
## Führen Sie Ihre erste Migration durch
|
||||
|
||||
Migrationsdateien befinden sich im Verzeichnis `application/settings/config/database/` als nummerierte SQL-Dateien (`001-acl.sql`, `002-account.sql`, …). Führen Sie alle aus mit:
|
||||
```bash
|
||||
./nibiru -mi local
|
||||
```
|
||||
Der Migrator notiert, was er angewendet hat, sodass ein Neuausführen sicher ist. Um gegen Staging oder Produktion anzuwenden, ändern Sie die Umgebung:
|
||||
```bash
|
||||
APPLICATION_ENV=production ./nibiru -mi production
|
||||
```
|
||||
:::caution[Zurücksetzbefehle sind zerstörend]
|
||||
`./nibiru -mi-reset {env}` löscht die Migrations-Tabelle und vergisst, welche Dateien angewendet wurden. Verwenden Sie es nur auf einer neu erstellten Datenbank, bei der es Ihnen egal ist, sie erneut zu befüllen.
|
||||
:::
|
||||
|
||||
## Erster Start
|
||||
|
||||
Zeigen Sie Ihren Webserver auf das Projektstammverzeichnis (das Verzeichnis, das `index.php` enthält). Für nginx verwenden Sie die enthaltene `vhost.conf` als Ausgangspunkt. Für Apache ist die standardmäßige `.htaccess`-Umleitung in `index.php` ausreichend.
|
||||
|
||||
Navigieren Sie zu `/`, und Sie sollten die Index-Vorlage sehen. Von dort führt [der Schnellstart](/start/quick-start/) Sie durch Ihren ersten Controller und Ihre erste Ansicht.
|
||||
124
docs/src/content/docs/de/start/local-testing.md
Normal file
124
docs/src/content/docs/de/start/local-testing.md
Normal file
@@ -0,0 +1,124 @@
|
||||
---
|
||||
title: "Lokal Ausführen"
|
||||
description: "Drei Möglichkeiten, die Dokumentationssite (mit dem Oracle) auf Ihrem eigenen Rechner zu starten."
|
||||
---
|
||||
|
||||
Die Dokumentationssite – und der Oracle, der sich im Ecke befindet – ist einfach eine Astro-App. Sie können sie auf drei Arten ausführen, abhängig davon, was Ihnen zur Verfügung steht.
|
||||
|
||||
## Option A — Astro Entwickler-Server, neuronetz.ai Backend (schnellste)
|
||||
|
||||
Der Oracle ruft Ihre gemeinsame Ollama unter `https://api.neuronetz.ai` auf. Keine lokale GPU erforderlich, keine API-Schlüssel zu verwalten.
|
||||
```bash
|
||||
cd /home/stephan/PhpstormProjects/Nibiru/docs
|
||||
|
||||
# .env (copy from .env.example, defaults already point at neuronetz.ai)
|
||||
cp .env.example .env
|
||||
|
||||
npm install # one-time
|
||||
npm run dev # http://localhost:4321
|
||||
```
|
||||
Öffnen Sie <http://localhost:4321/>. Klicken Sie auf den orangefarbenen Oracle-Launcher unten rechts und stellen Sie eine Frage.
|
||||
|
||||
**Ziehen Sie das Einbettungsmodell einmal** auf Ihrem Ollama-Host (Build-Zeit + Laufzeit) herunter:
|
||||
```bash
|
||||
curl https://api.neuronetz.ai/api/pull -d '{"name":"nomic-embed-text"}'
|
||||
```
|
||||
Ohne es funktioniert der Oracle trotzdem – er läuft nur im Chat-Modus (ohne RAG) und antwortet auf Basis des modellbasierten Wissens. Mit ihm sind die Antworten an dieser Dokumentation angeordnet.
|
||||
|
||||
### Erstellen Sie den Embedding-Index
|
||||
```bash
|
||||
npm run build:oracle # writes public/oracle-index.json
|
||||
```
|
||||
Der Entwicklungs-Server wird es bei der nächsten Anfrage aufnehmen. Oder überspringen Sie diesen Schritt vollständig – der Laufzeitendpunkt verarbeitet einen fehlenden oder leeren Index sanft.
|
||||
|
||||
### Laufzeitkonfiguration überprüfen
|
||||
|
||||
Der Endpunkt `/api/oracle` des Orakels antwortet auch auf GET mit seiner aktuellen Konfiguration (keine Geheimnisse):
|
||||
```bash
|
||||
curl http://localhost:4321/api/oracle
|
||||
# {"status":"ok","llm":{"provider":"ollama","ollamaUrl":"https://api.neuronetz.ai",
|
||||
# "model":"qwen2.5-coder:14b"},"embed":{...},"index":{...}}
|
||||
```
|
||||
## Option B — Docker Compose, lokal
|
||||
|
||||
Näher an der Produktion. Baut das gleiche Image, das Sie schippen würden.
|
||||
```bash
|
||||
cd /home/stephan/PhpstormProjects/Nibiru/docs
|
||||
cp .env.example .env
|
||||
|
||||
docker compose -f docker-compose.yml -f docker-compose.local.yml up --build
|
||||
```
|
||||
Die `docker-compose.local.yml` Überschreibung:
|
||||
|
||||
- Veröffentlicht `4321:4321`, sodass die Anwendung unter <http://localhost:4321/> erreichbar ist.
|
||||
- Entfernt die Umgebungsvariablen `VIRTUAL_HOST` / `LETSENCRYPT_HOST`.
|
||||
- Braucht kein `nginx-proxy` Netzwerk auf Ihrem Entwicklungssystem.
|
||||
|
||||
Der erste Build dauert 1-2 Minuten. Nachfolgende Neuaufbauten sind schnell (Docker speichert die Dep-Layer im Cache).
|
||||
|
||||
## Option C — Völlig offline, lokale Ollama
|
||||
|
||||
Für airgapped Devs, Demos im Flugzeug oder das Brennen eigener GPUs.
|
||||
```bash
|
||||
# 1) Run Ollama on your laptop
|
||||
ollama serve &
|
||||
|
||||
# 2) Pull a chat model (any of these works)
|
||||
ollama pull qwen2.5-coder:14b # 8 GB, recommended
|
||||
# ollama pull qwen2.5-coder:1.5b # 1 GB, faster but less accurate
|
||||
# ollama pull llama3.2:3b # 2 GB, alternative
|
||||
|
||||
# 3) Pull an embedding model
|
||||
ollama pull nomic-embed-text
|
||||
|
||||
# 4) Point the docs at it
|
||||
cd /home/stephan/PhpstormProjects/Nibiru/docs
|
||||
cat > .env <<EOF
|
||||
OLLAMA_BASE_URL=http://127.0.0.1:11434
|
||||
OLLAMA_CHAT_MODEL=qwen2.5-coder:14b
|
||||
OLLAMA_EMBED_MODEL=nomic-embed-text
|
||||
EOF
|
||||
|
||||
npm run build:oracle
|
||||
npm run dev
|
||||
```
|
||||
Jetzt ruft `http://localhost:4321/api/oracle` Ihren Laptop's Ollama auf. Keine Internetverbindung erforderlich.
|
||||
|
||||
## Wechsel zu Anthropic / OpenAI (falls Sie möchten)
|
||||
|
||||
Der Oracle unterstützt bezahlte APIs als Ersatzlösung. In `.env`:
|
||||
```bash
|
||||
LLM_PROVIDER=anthropic
|
||||
ANTHROPIC_API_KEY=sk-ant-...
|
||||
|
||||
EMBED_PROVIDER=openai
|
||||
OPENAI_API_KEY=sk-...
|
||||
```
|
||||
Nützlich, wenn Ihr Ollama ausfällt oder um die Antwortqualität zwischen Anbietern zu vergleichen.
|
||||
|
||||
## Rauchtest-Schnellkennblatt
|
||||
```bash
|
||||
curl http://localhost:4321/ # 301 → /en/
|
||||
curl -I http://localhost:4321/en/ # 200
|
||||
curl http://localhost:4321/api/oracle # GET = config
|
||||
curl -X POST -H 'Content-Type: application/json' \
|
||||
-d '{"messages":[{"role":"user","content":"How do I create a module?"}]}' \
|
||||
http://localhost:4321/api/oracle | jq .answer
|
||||
```
|
||||
Wenn der letzte Aufruf eine echte Antwort zurückgibt, die `./nibiru -m` erwähnt, ist Ihr Stack korrekt verbunden.
|
||||
|
||||
## Problembehandlung
|
||||
|
||||
**Oracle gibt "der Oracle konnte nicht antworten".**
|
||||
Der Ollama-Server ist nicht erreichbar oder das Chat-Modell wurde nicht abgerufen. Überprüfen Sie:```bash
|
||||
curl https://api.neuronetz.ai/api/tags | jq '.models[].name'
|
||||
```
|
||||
**Oracle antwortet ohne Zitate.**
|
||||
Der Einbettungsindex ist leer. Führen Sie nach dem Pullen von `nomic-embed-text` den Befehl `npm run build:oracle` erneut aus.
|
||||
|
||||
**Ollama gibt 404 model-not-found zurück.**
|
||||
Laden Sie das Modell herunter, z.B.:```bash
|
||||
curl https://api.neuronetz.ai/api/pull -d '{"name":"qwen2.5-coder:14b"}'
|
||||
```
|
||||
**`ECONNREFUSED 127.0.0.1:11434`** in Option C.
|
||||
Der lokale Ollama läuft nicht. Starten Sie ihn mit `ollama serve &` (oder über Ihren Systemdienst).
|
||||
127
docs/src/content/docs/de/start/quick-start.md
Normal file
127
docs/src/content/docs/de/start/quick-start.md
Normal file
@@ -0,0 +1,127 @@
|
||||
---
|
||||
title: "Schnellstart"
|
||||
description: "Erstellen Sie eine minimale Produkte-Seite in fünf Minuten – Controller, Ansicht, Navigations-Eintrag."
|
||||
---
|
||||
|
||||
Am Ende dieser Seite haben Sie eine funktionierende `/products`-Seite, die von Smarty gerendert wird, die durch einen Controller gefüttert und im Seitenleisten-Menü aufgelistet ist.
|
||||
|
||||
## 1. Erstellen Sie einen Controller
|
||||
```bash
|
||||
./nibiru -c products
|
||||
```
|
||||
Dies erstellt zwei Dateien:
|
||||
```
|
||||
application/controller/productsController.php
|
||||
application/view/templates/products.tpl
|
||||
```
|
||||
## 2. Verbinden Sie den Controller
|
||||
|
||||
Öffnen Sie `productsController.php` und ersetzen Sie den Inhalt durch:
|
||||
```php
|
||||
<?php
|
||||
namespace Nibiru;
|
||||
use Nibiru\Adapter\Controller;
|
||||
|
||||
class productsController extends Controller
|
||||
{
|
||||
public function pageAction()
|
||||
{
|
||||
View::assign([
|
||||
'title' => 'Products — Nibiru',
|
||||
'products' => [
|
||||
['id' => 1, 'name' => 'Marduk Gold Plating', 'price' => 99.0],
|
||||
['id' => 2, 'name' => 'Tiamat Hull Sealant', 'price' => 49.0],
|
||||
['id' => 3, 'name' => 'Anu Stardust Polish', 'price' => 19.5],
|
||||
],
|
||||
'css' => Config::getInstance()->getConfig()[View::NIBIRU_SETTINGS]['smarty.css'],
|
||||
'js' => Config::getInstance()->getConfig()[View::NIBIRU_SETTINGS]['smarty.js'],
|
||||
]);
|
||||
}
|
||||
|
||||
public function navigationAction()
|
||||
{
|
||||
JsonNavigation::getInstance()->loadJsonNavigationArray();
|
||||
}
|
||||
}
|
||||
```
|
||||
Zwei Methoden sind konventionell und werden **immer aufgerufen** durch den [Dispatcher](/core/dispatcher/):
|
||||
|
||||
- `navigationAction()` — füllt das globale Navigationsmenü auf.
|
||||
- `pageAction()` — rendert die Seite selbst.
|
||||
|
||||
Alles, was `?_action=foo` entspricht, wird zusätzliche eine `fooAction()`-Methode aufrufen.
|
||||
|
||||
## 3. Schreiben Sie die Ansicht
|
||||
|
||||
Öffnen Sie `application/view/templates/products.tpl`:
|
||||
```smarty
|
||||
{include 'shared/header.tpl'}
|
||||
<body>
|
||||
{include file="navigation.tpl"}
|
||||
|
||||
<main class="container">
|
||||
<h1>{$title}</h1>
|
||||
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr><th>#</th><th>Name</th><th>Price</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{foreach $products as $p}
|
||||
<tr>
|
||||
<td>{$p.id}</td>
|
||||
<td><a href="/products/detail/{$p.id}">{$p.name|escape}</a></td>
|
||||
<td>{$p.price|string_format:"%.2f"} €</td>
|
||||
</tr>
|
||||
{/foreach}
|
||||
</tbody>
|
||||
</table>
|
||||
</main>
|
||||
|
||||
{include 'shared/footer.tpl'}
|
||||
</body>
|
||||
```
|
||||
Variablen, die an `View::assign()` übergeben werden, erscheinen als `{$variable}` in Smarty.
|
||||
|
||||
## 4. Fügen Sie einen Navigationseintrag hinzu
|
||||
|
||||
Bearbeiten Sie `application/settings/config/navigation/main.json` und fügen Sie hinzu:
|
||||
```json
|
||||
{
|
||||
"label": "Products",
|
||||
"href": "/products",
|
||||
"icon": "shopping-bag"
|
||||
}
|
||||
```
|
||||
Die Navigation wird von `JsonNavigation` geladen und mit `navigation.tpl` gerendert.
|
||||
|
||||
## 5. Führen Sie es aus
|
||||
|
||||
Falls Sie den eingebauten Server von PHP zur Verfügung haben:
|
||||
```bash
|
||||
APPLICATION_ENV=development php -S localhost:8080 -t .
|
||||
```
|
||||
Besuchen Sie <http://localhost:8080/products/>. Sie sollten Ihre drei Produkte mit dem kosmischen Thema Ihres CSS sehen.
|
||||
|
||||
## Eine Detailseite hinzufügen
|
||||
|
||||
Im gleichen Controller:
|
||||
```php
|
||||
public function detailAction()
|
||||
{
|
||||
$id = (int) ($_REQUEST['id'] ?? 0);
|
||||
View::assign([
|
||||
'title' => "Product #$id",
|
||||
'id' => $id,
|
||||
]);
|
||||
}
|
||||
```
|
||||
`Router` versteht bereits `/products/detail/42` und `/products/marduk-gold-plating/42` (SEO-URL-Form – `id` und `slug` werden automatisch in `$_REQUEST` aufgefüllt).
|
||||
|
||||
Erstellen Sie `application/view/templates/products/detail.tpl` mit beliebiger Markupsprache, und Sie haben eine zweiseitige Anwendung.
|
||||
|
||||
## Wo geht's weiter?
|
||||
|
||||
- [Architektur (MMVC)](/core/architecture/) — wie alle beweglichen Teile zusammenpassen.
|
||||
- [Module](/core/modules/) — wann man von Controllern und Traits zu einem echten Modul wechselt.
|
||||
- [Datenbank & Migrationen](/core/database/) — verbinde die Seite mit einer tatsächlichen `products` Tabelle.
|
||||
66
docs/src/content/docs/de/start/structure.md
Normal file
66
docs/src/content/docs/de/start/structure.md
Normal file
@@ -0,0 +1,66 @@
|
||||
---
|
||||
title: "Projektstruktur"
|
||||
description: "Eine durchgeführte Tour jedes Verzeichnisses eines Nibiru-Projekts."
|
||||
---
|
||||
```
|
||||
my-app/
|
||||
├── core/ # Framework code (don't edit)
|
||||
│ ├── a/ # Abstract classes + adapters
|
||||
│ ├── c/ # Concrete classes (router, view, form types…)
|
||||
│ ├── f/ # Factories (db, form)
|
||||
│ ├── i/ # Interfaces
|
||||
│ ├── l/ # Composer vendor (yes, in core/)
|
||||
│ ├── t/ # Traits
|
||||
│ └── framework.php # Main bootstrap
|
||||
├── application/ # Your app
|
||||
│ ├── controller/ # *Controller.php files
|
||||
│ ├── model/ # Auto-generated models (from DB schema)
|
||||
│ ├── module/ # Modules (the second M in MMVC)
|
||||
│ │ └── users/ # each with its own MVC + plugins
|
||||
│ ├── settings/
|
||||
│ │ └── config/
|
||||
│ │ ├── settings.<env>.ini
|
||||
│ │ ├── database/ # numbered SQL migration files
|
||||
│ │ └── navigation/main.json
|
||||
│ └── view/
|
||||
│ ├── templates/ # Smarty .tpl files
|
||||
│ ├── templates_c/ # Smarty compile cache (auto)
|
||||
│ ├── cache/ # HTML cache (when caching=true)
|
||||
│ └── mockup/ # Static design mockups
|
||||
├── public/ # CSS / JS / images / fonts (web-served)
|
||||
├── nibiru # CLI binary
|
||||
├── index.php # Entry point
|
||||
├── composer.json
|
||||
└── vhost.conf # Sample nginx vhost
|
||||
```
|
||||
## Bemerkenswerte Konventionen
|
||||
|
||||
### Controller
|
||||
- Datei: `application/controller/<name>Controller.php`
|
||||
- Klasse: `Nibiru\<name>Controller extends Nibiru\Adapter\Controller`
|
||||
- Erforderliche Methoden: `pageAction()`, `navigationAction()`. Optional: jede `<verb>Action()`.
|
||||
|
||||
### Ansichten
|
||||
- Datei: `application/view/templates/<name>.tpl` für den entsprechenden Controller.
|
||||
- Unteransichten befinden sich unter `application/view/templates/<name>/<action>.tpl`.
|
||||
- Gemeinsame Teile liegen in `application/view/templates/shared/`.
|
||||
|
||||
### Modelle
|
||||
- Automatisch unter `application/model/` generiert.
|
||||
- Dateinamen entsprechen Tabellennamen. Jede Klasse erweitert einen `Db` Adapter (`MySQL\Db` oder `PostgreSQL\Db`).
|
||||
|
||||
### Module
|
||||
- Ordner: `application/module/<name>/`
|
||||
- Jedes Modul ist eine eigene MVC-Insel plus Traits, Plugins, Schnittstellen und Einstellungen.
|
||||
- Der [Registry](/core/registry/) entdeckt automatisch die `*.ini`-Einstellungen innerhalb des `settings/`-Ordners jedes Moduls.
|
||||
|
||||
### Migrationen
|
||||
- `application/settings/config/database/<NNN>-<slug>.sql`
|
||||
- Nummeriert. Führen Sie über `./nibiru -mi <env>` aus.
|
||||
- Eine Datei pro logischer Änderung – vermeiden Sie die Zusammenführung.
|
||||
|
||||
### Composer Vendor in `core/l/`
|
||||
Dies ist absichtlich. `composer.json` setzt `vendor-dir: core/l`. Der Autoloader des Frameworks erwartet es dort. Bewegen Sie es nicht.
|
||||
|
||||
### `templates_c/` und `cache/`
|
||||
Smarty muss in die `templates_c/` schreiben können. Das Zwischenspeichern ist über den INI-Schlüssel `[ENGINE] caching = true` optional. Beide Ordner sollten in Ihrer `.gitignore` enthalten sein.
|
||||
77
docs/src/content/docs/de/start/what-is-nibiru.md
Normal file
77
docs/src/content/docs/de/start/what-is-nibiru.md
Normal file
@@ -0,0 +1,77 @@
|
||||
---
|
||||
title: Was ist Nibiru?
|
||||
description: Eine 90-Sekunden-Tour durch Nibiru — was MMVC bedeutet, was im Karton ist und für wen es gedacht ist.
|
||||
---
|
||||
|
||||
**Nibiru** ist ein modulares MVC-PHP-Framework — *MMVC* — gebaut für **rapides Prototyping**, ohne die Disziplin eines echten Frameworks aufzugeben. Klein genug, um in den Kopf zu passen, mächtig genug, um Produktions-Apps zu tragen, wie sie auf der [Showcase-Seite](/de/showcase/projects/) zu sehen sind.
|
||||
|
||||
Der Name nickt der babylonischen Astronomie zu: **Nibiru** war der Himmelsdurchgang, der Marduk, dem obersten Gott Babylons, zugeordnet wurde. Das Framework folgt der gleichen Idee — ein einziger Punkt, an dem deine Module, Controller, Views und Daten einander begegnen.
|
||||
|
||||
## Was im Karton steckt
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Routing & Dispatch** | URL-Pattern + SEO-URL-Parsing, Soft-404, automatische Action-Auflösung. |
|
||||
| **MVC + ein zweites M** | Controller, Views (Smarty), Models, plus erstklassige **Module** mit Traits, Plugins, Interfaces, Settings und Observer-Muster. |
|
||||
| **Multi-Datenbank** | Native MySQL, PDO, PostgreSQL über libpq (`psql`/`postgresql`) und ODBC, alle hinter einem einheitlichen `Db`-Adapter. |
|
||||
| **Forms** | Über 28 Feldtypen, fließend gebaut mit `Form::addInputType…()` und Layout-Hilfen für Divs. |
|
||||
| **Pagination** | URL-bewusst (`/controller/action/page/N`) mit Template-Helfern. |
|
||||
| **Auth** | Session-basiert mit AES-entschlüsselten Anmeldedaten, Login-Form vom `Users`-Modul. |
|
||||
| **CLI (`./nibiru`)** | Generiert Module, Controller, Plugins; führt Migrationen aus; löscht Caches; managt CMS-Seiten. |
|
||||
| **Composer-fertig** | Smarty 3, PHPMailer, Guzzle, Laminas Diactoros, OpenAI-Client, Elasticsearch-Client, QR-Codes, Barcodes, Blockchain-Tools. |
|
||||
|
||||
## Was MMVC eigentlich bedeutet
|
||||
|
||||
Die meisten PHP-Frameworks geben dir Model–View–Controller. Nibiru fügt ein zweites **M** hinzu: **Modul**.
|
||||
|
||||
Ein Modul ist eine in sich geschlossene Einheit, die enthalten kann:
|
||||
|
||||
- eine Hauptklasse, die `IModule` implementiert (und optional `SplSubject` für das Observer-Muster),
|
||||
- **Traits** für wiederverwendbares Verhalten,
|
||||
- **Plugins** für zustandslose Services, die in Controller injiziert werden,
|
||||
- **Interfaces** für Verträge,
|
||||
- **Settings** als `.ini`-Dateien, die vom [Registry](/de/core/registry/) automatisch entdeckt werden.
|
||||
|
||||
Module fördern lose Kopplung und erlauben dir, "users", "billing", "shop", "tpms" usw. in eigenen Ordnern zu halten, ohne `application/controller/` zu überfüllen.
|
||||
|
||||
```
|
||||
application/module/users/
|
||||
├── users.php # Hauptklasse (implementiert IModule, SplSubject)
|
||||
├── interfaces/ # Verträge
|
||||
├── plugins/ # zustandslose Services (User, Acl…)
|
||||
├── settings/ # .ini-Konfig automatisch geladen vom Registry
|
||||
└── traits/ # wiederverwendbare Methoden
|
||||
```
|
||||
|
||||
## Der Request-Lifecycle, in einem Atemzug
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[index.php] --> B[core/framework.php]
|
||||
B --> C[Dispatcher::run]
|
||||
C --> D[Router::route]
|
||||
D --> E[Auto::loader<br/>Models + Module]
|
||||
E --> F[applicationController.php]
|
||||
F --> G[navigationAction]
|
||||
G --> H[customAction?]
|
||||
H --> I[pageAction]
|
||||
I --> J[Display::display<br/>Smarty-Render]
|
||||
```
|
||||
|
||||
Jeder Request führt `navigationAction()` aus, dann (optional) die getroffene `_action`Action und schließlich `pageAction()`. Wenn die Controller-Datei nicht gefunden wird, rendert Nibiru das konfigurierte Error-Template — ein *Soft-404*.
|
||||
|
||||
:::tip[Kommst du von Laravel oder Symfony?]
|
||||
Du wirst dich mit Controllern, Views und Models zu Hause fühlen. Die Unterschiede:
|
||||
|
||||
- **Kein Service-Container** — Nibiru stützt sich auf **Singletons** (`Config`, `Router`, `Registry`, `Dispatcher`).
|
||||
- **Kein Eloquent / Doctrine** — Models werden automatisch aus deinem Datenbankschema von `Model::__construct(false)` generiert.
|
||||
- **Smarty, nicht Blade oder Twig** — aber das Muskelgedächtnis ist dasselbe.
|
||||
:::
|
||||
|
||||
## Für wen Nibiru ist
|
||||
|
||||
- **Hacker und Prototyper**, die an diesem Wochenende eine funktionierende Web-App ausliefern wollen.
|
||||
- **Solo-Gründer und kleine Teams**, die echte Produktions-Apps warten, ohne das Gewicht eines Megaframeworks zu tragen.
|
||||
- **PHP-neugierige Teams**, die ab Tag eins PostgreSQL, MySQL und ein CLI haben wollen.
|
||||
|
||||
Bereit? [Installiere Nibiru →](/de/start/installation/)
|
||||
157
docs/src/content/docs/de/why-nibiru.md
Normal file
157
docs/src/content/docs/de/why-nibiru.md
Normal file
@@ -0,0 +1,157 @@
|
||||
---
|
||||
title: "Warum Nibiru und nicht Laravel?"
|
||||
description: "Fünf Dinge, die Nibiru anders als Laravel und Symfony macht – jeweils unterpuntet durch echten Produktionscode."
|
||||
---
|
||||
|
||||
Die meisten Vergleiche von PHP-Frameworks sind eher subjektiv. Dieser ist nachvollziehbar.
|
||||
|
||||
Die fünf Unterschiedsmaker unten stammen aus einer realen Einnahmengenerierungswebsite: **[maschinen-stockert.de](/en/showcase/projects/)** — einem multilingualen Industriemaschinen-Marktplatz. **36 Controller, 18 Module, 348 Vorlagen, 161 SQL-Migrationen, 37.369 Zeilen PHP** über zwei Repositories. Jede Behauptung verweist auf einen Datei:Zeilenverweis innerhalb der Codebasen, sodass Sie es überprüfen können.
|
||||
|
||||
Wenn Sie mit Laravel oder Symfony gearbeitet haben, werden Sie die Muster erkennen, die Nibiru nicht hat. Das ist der Punkt.
|
||||
|
||||
---
|
||||
|
||||
## 1. Integrierte CMS-Schicht in das Framework
|
||||
|
||||
In Laravel befindet sich die Seitenkopie in Blade-Dateien (`{{ __('page.title') }}`) oder in der Übersetzungsdatei JSON. Um sie zu ändern, bearbeitet ein Entwickler eine Datei und führt einen Deployment durch. In Nibiru befindet sich die Seitenkopie in der Datenbank, mit dem Schlüssel `<controller>/<action>` + Sprache, und Redakteure aktualisieren sie über die Admin-Oberfläche **ohne Code zu berühren**.
|
||||
```php
|
||||
// data.maschinen-stockert.de/application/controller/maschineController.php
|
||||
public function pageAction()
|
||||
{
|
||||
$controllerPath = $this->getController() . '/' . $this->getRequest('_action', 'page');
|
||||
|
||||
foreach (Cms::init($this->getController())
|
||||
->loadCmsTemplateTextsByControllerPath($controllerPath, $this->language)
|
||||
as $t) {
|
||||
View::assign([
|
||||
$t['cms_template_texts_text_identifier']
|
||||
=> $t['cms_template_texts_text_content']
|
||||
]);
|
||||
}
|
||||
}
|
||||
```
|
||||
**Gesamtwirkung**: Marketing ändert einen Headline im Editor, aktualisiert die Seite und veröffentlicht. Kein PR, kein Deployment, kein Entwickler im Begriff. Das ist kein CMS-Plugin – das Modul `Cms` des Frameworks ist eingebaut.
|
||||
|
||||
→ Siehe [Module](/de/core/modules/) für die Art und Weise, wie das CMS-Modul sich zusammensetzt.
|
||||
|
||||
---
|
||||
|
||||
## 2. Module bestehen aus Merkmalen (Traits), nicht aus einem Dienstcontainer
|
||||
|
||||
Laravels Antwort auf eine Klasse mit 5.000 Zeilen sind Service-Provider und Dependency Injection. Nibirus Antwort ist **Traits**.
|
||||
|
||||
Das CMS-Modul auf `maschinen-stockert.de` besteht aus **13 Merkmalen**, wobei jedes ein einzelner Verantwortungsbereich ist — `CmsStore`, `TextsForm`, `PageBuilderForm`, `CmsPageStructureModifier`, `FormElements`, … Die Hauptklasse `Cms` umfasst 30 Zeilen, die sie zusammenfügen. Das Hinzufügen einer neuen Funktion lautet *"eine Merkmale erstellen, einbinden, fertig."*
|
||||
```php
|
||||
// application/module/cms/cms.php
|
||||
class Cms implements Interfaces\Cms, SplSubject
|
||||
{
|
||||
use Traits\CmsStore;
|
||||
use Traits\TextsForm;
|
||||
use Traits\PageBuilderForm;
|
||||
use Traits\CmsPageStructureModifier;
|
||||
use Traits\FormElements;
|
||||
// …8 more traits
|
||||
}
|
||||
```
|
||||
**Gesamtwirkung**: keine Konstruktor-Injektion, keine Dienstleistungsanbieterregistrierung, keine Aufrufe von `bind()` / `singleton()` in einer Konfigurationsdatei. Ein neuer Entwickler kann nach dem Trait-Namen suchen und alle Aufrufer sehen.
|
||||
|
||||
---
|
||||
|
||||
## 3. Direkte SQL mit JSON-Aggregation, keine ORM
|
||||
|
||||
Eloquent und Doctrine verpflichten zu einer Gebühr: jede geladene Beziehung könnte eine N+1-Abfrage sein, und jede Aggregation, die in PHP übertragen wird, ist Speicher verschwendet. Nibiru setzt sich auf die Datenbank, wo sie stark ist.
|
||||
```sql
|
||||
-- application/module/machineryscout/traits/machinesElasticSearch.php
|
||||
SELECT
|
||||
m.ms_machines_id AS machine_id,
|
||||
m.ms_machines_name AS machine_name,
|
||||
JSON_ARRAYAGG(
|
||||
DISTINCT JSON_OBJECT(
|
||||
'attribute_name', ma.ms_machine_attributes_attribute_name,
|
||||
'attribute_value', mav.ms_machine_attribute_values_value,
|
||||
'attribute_type', ma.ms_machine_attributes_attribute_type
|
||||
)
|
||||
) AS attributes,
|
||||
JSON_ARRAYAGG(DISTINCT mi.ms_machine_images_filename
|
||||
ORDER BY mi.ms_machine_images_sort_order ASC) AS images
|
||||
FROM ms_machines m
|
||||
LEFT JOIN ms_machine_attribute_values mav ON mav.ms_machine_attribute_values_machine_id = m.ms_machines_id
|
||||
LEFT JOIN ms_machine_attributes ma ON mav.ms_machine_attribute_values_attribute_id = ma.ms_machine_attributes_id
|
||||
LEFT JOIN ms_machine_images mi ON mi.ms_machines_id = m.ms_machines_id
|
||||
WHERE m.ms_active = 1
|
||||
GROUP BY m.ms_machines_id;
|
||||
```
|
||||
Eine Anfrage. Aggregierte JSON-Attribute und Bilder, indiziert aus der Datenbank. Das Ergebnis fließt direkt in Elasticsearch ein, bereit, Bereichsanfragen auf Maße wie *"2500 × 1200 mm"* zu unterstützen.
|
||||
|
||||
**Gesamtwirkung**: Probleme des Typs N+1 existieren nicht. Überprüfbar, profilierbar und schnell.
|
||||
|
||||
→ Siehe [Datenbank & Migrationen](/de/core/database/) für den `Pdo` Adapter, der Abfragen wie diese ausführt.
|
||||
|
||||
---
|
||||
|
||||
## 4. Rollenbasierte ACL ohne Middleware-Ketten
|
||||
|
||||
In Laravel ist die Autorisierung ein Stack: `auth` Middleware → Policy-Klasse → Gate → Controller. In Nibiru sind es drei Zeilen im Konstruktor:
|
||||
```php
|
||||
// data.maschinen-stockert.de/application/controller/adminController.php
|
||||
public function __construct() {
|
||||
parent::__construct();
|
||||
$this->user = new User();
|
||||
$this->acl = new Acl();
|
||||
$this->acl->init();
|
||||
$this->user->validate();
|
||||
}
|
||||
```
|
||||
`$this->acl->init()` lädt die Rollen-/Berechtigungsmenge für die aktuelle Sitzung. `$this->user->validate()` überprüft, ob die Sitzung gültig ist. Wenn einer dieser Schritte fehlschlägt, wird die Anfrage die Login-Seite zurückgeben. Berechtigungsprüfungen pro Aktion (`$this->acl->can('edit', 'pages')`) befinden sich in den Aktionen, die sie benötigen.
|
||||
|
||||
Der API-Controller nimmt den umgekehrten Ansatz: eine Whitelist öffentlicher Endpunkte im Konstruktor und Authentifizierung für alles andere.
|
||||
```php
|
||||
// public endpoints can be listed up-front, auth wraps the rest.
|
||||
if (in_array($action, ['category', 'machines', 'ollama', 'team'])) {
|
||||
// public, skip auth
|
||||
} else {
|
||||
$this->user = new User();
|
||||
$this->acl = new Acl();
|
||||
$this->acl->init();
|
||||
$this->user->validate();
|
||||
}
|
||||
```
|
||||
**Gesamtwirkung**: Die Autorisierung ist ein durchsuchbares Ausdruck. Keine Probleme mit der Reihenfolge des Middlewares. Keine Geheimnisse wie „Warum ist dieser Endpunkt öffentlich?“.
|
||||
|
||||
→ Siehe [Auth](/de/core/auth/) für Details zu Sitzungen und Zugriffssteuerung.
|
||||
|
||||
---
|
||||
|
||||
## 5. Beobachtermuster, nicht Ereignisdispatcher
|
||||
|
||||
Die Ereignisse von Laravel werden über einen Dispatcher mit Schließlinsen, Listenern und Aufzeichnungen für wartige Jobs ausgegeben. Nibiru verwendet die PHP-Standardbibliothek-Schnittstellen `SplSubject` / `SplObserver`. Gleiches Muster, zwei Schnittstellen, keine Abstraktionsebene.
|
||||
```php
|
||||
class Machineryscout implements IModule, \SplSubject
|
||||
{
|
||||
private \SplObjectStorage $observers;
|
||||
|
||||
public function attach(\SplObserver $o): void { $this->observers->attach($o); }
|
||||
public function detach(\SplObserver $o): void { $this->observers->detach($o); }
|
||||
public function notify(): void {
|
||||
foreach ($this->observers as $o) $o->update($this);
|
||||
}
|
||||
|
||||
public function indexMachines(): void {
|
||||
// …do the work…
|
||||
$this->notify(); // analytics, cache invalidator, audit log all see it.
|
||||
}
|
||||
}
|
||||
```
|
||||
**Gesamtwirkung**: Zustandsänderungen sind synchron, deterministisch und debugbar. Kein Queue Worker. Keine "hat der Listener gefeuert?" Geheimnis. Fügen Sie einen Observer im Controller hinzu, und er wird verbunden.
|
||||
|
||||
→ Siehe [Module](/de/core/modules/#der-beobachtermuster) für das Muster.
|
||||
|
||||
---
|
||||
|
||||
## Was Sie damit erhalten, in zwei Sätzen
|
||||
|
||||
Ein einzelner Entwickler oder eine kleine Team kann eine echte Produktions-App bauen und *betreiben* – wie zum Beispiel eine, die Maschinen in 12 Ländern verkauft – mit **weniger Frameworks zu lernen**, **weniger Abstraktionen zu debuggen** und **weniger Zeremonien pro Funktion**. Der Kompromiss ist, dass Sie lieber möchten, dass das Framework jede Datenbankabfrage verbirgt und jedes Ereignis orchestriert, dann sind Sie an einem anderen Ort glücklicher.
|
||||
|
||||
Wenn Sie lieber Ihren Code sehen möchten, nehmen Sie ihn.
|
||||
|
||||
→ [Die Präsentation ansehen →](/de/praesentation/projekte/)
|
||||
138
docs/src/content/docs/en/ai/corpus.md
Normal file
138
docs/src/content/docs/en/ai/corpus.md
Normal file
@@ -0,0 +1,138 @@
|
||||
---
|
||||
title: Training Corpus
|
||||
description: How the docs are exported as a LoRA-ready training set, and how to regenerate it.
|
||||
---
|
||||
|
||||
Every page in this documentation is also a **training data point**. Nibiru ships a script that extracts a clean JSONL corpus suitable for LoRA fine-tuning of an open-weight model — Llama, Mistral, Qwen, Gemma — on Nibiru-specific knowledge.
|
||||
|
||||
## Run it
|
||||
|
||||
```bash
|
||||
cd docs
|
||||
npm run build:corpus
|
||||
```
|
||||
|
||||
This writes:
|
||||
|
||||
```
|
||||
docs/dist/corpus/
|
||||
├── instructions.jsonl # instruction → response pairs
|
||||
├── chat.jsonl # OpenAI/Anthropic chat-message format
|
||||
├── completion.jsonl # plain prompt → completion (legacy)
|
||||
└── chunks.jsonl # raw Markdown chunks (one per H2/H3 section)
|
||||
```
|
||||
|
||||
## Formats
|
||||
|
||||
### `instructions.jsonl`
|
||||
|
||||
LoRA-friendly instruction tuning:
|
||||
|
||||
```json
|
||||
{
|
||||
"instruction": "How do I scaffold a new module in Nibiru?",
|
||||
"input": "",
|
||||
"output": "Run `./nibiru -m <name>`, optionally with `-g` for Graylog hooks. This creates `application/module/<name>/` with traits/, plugins/, interfaces/, settings/<name>.ini and the main `<name>.php` class implementing `IModule`."
|
||||
}
|
||||
```
|
||||
|
||||
Each entry is generated from a docs section with a clear question (derived from the H2/H3 title) and the section body as the answer.
|
||||
|
||||
### `chat.jsonl`
|
||||
|
||||
OpenAI chat / Anthropic Messages format:
|
||||
|
||||
```json
|
||||
{
|
||||
"messages": [
|
||||
{"role": "system", "content": "You are an expert on the Nibiru PHP framework."},
|
||||
{"role": "user", "content": "How do I scaffold a new module?"},
|
||||
{"role": "assistant", "content": "Run `./nibiru -m <name>`. …"}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Compatible with OpenAI fine-tunes, Anthropic's API for evaluation, and most LoRA tooling expecting chat-format inputs (Axolotl's `sharegpt` template, Unsloth, LLaMA-Factory).
|
||||
|
||||
### `chunks.jsonl`
|
||||
|
||||
Raw chunks for use as RAG retrieval data:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "core/modules#observer-pattern",
|
||||
"title": "The observer pattern",
|
||||
"url": "/core/modules/#the-observer-pattern",
|
||||
"section": "core/modules",
|
||||
"language": "en",
|
||||
"tokens": 412,
|
||||
"content": "Modules implementing `SplSubject` can broadcast events…"
|
||||
}
|
||||
```
|
||||
|
||||
This is exactly the file the [Oracle](/ai/oracle/) uses internally.
|
||||
|
||||
## How chunks are derived
|
||||
|
||||
The corpus builder walks every `.md` / `.mdx` file under `src/content/docs/`, parses it into an AST, and chunks it at H2/H3 boundaries. It enforces:
|
||||
|
||||
- One chunk per H2 section (or H3 if the H2 is empty).
|
||||
- ~200–800 tokens per chunk (split if longer, merge if shorter).
|
||||
- Code fences are kept intact — never split mid-block.
|
||||
- Each chunk carries its source path, anchor URL, and language code.
|
||||
|
||||
The script is in `scripts/build-corpus.mjs` and is fully configurable.
|
||||
|
||||
## Suggested LoRA recipe
|
||||
|
||||
A pragmatic baseline for an 8B-parameter base model on a single A100 / 4090:
|
||||
|
||||
```yaml
|
||||
# axolotl.yaml
|
||||
base_model: meta-llama/Llama-3.1-8B-Instruct
|
||||
adapter: lora
|
||||
lora_r: 16
|
||||
lora_alpha: 32
|
||||
lora_dropout: 0.05
|
||||
lora_target_modules: [q_proj, k_proj, v_proj, o_proj]
|
||||
|
||||
datasets:
|
||||
- path: docs/dist/corpus/chat.jsonl
|
||||
type: sharegpt
|
||||
|
||||
sequence_len: 4096
|
||||
sample_packing: true
|
||||
gradient_accumulation_steps: 4
|
||||
micro_batch_size: 2
|
||||
num_epochs: 3
|
||||
optimizer: adamw_bnb_8bit
|
||||
learning_rate: 0.0002
|
||||
warmup_ratio: 0.05
|
||||
bf16: true
|
||||
```
|
||||
|
||||
Train, then merge the LoRA weights and serve via Ollama, vLLM, or text-generation-inference. Swap the Oracle's `MODEL` to point at your local endpoint and you have a fully Nibiru-native chat UX.
|
||||
|
||||
## Re-run on every doc change
|
||||
|
||||
Wire it into your CI:
|
||||
|
||||
```yaml
|
||||
- name: Build corpus
|
||||
run: cd docs && npm run build:corpus
|
||||
- name: Upload corpus artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: nibiru-corpus
|
||||
path: docs/dist/corpus/
|
||||
```
|
||||
|
||||
When docs change, the corpus re-builds; consumers (training pipelines, RAG indexes) always have the freshest data.
|
||||
|
||||
## Languages
|
||||
|
||||
The corpus respects locale. Pages under `en/` are tagged `language: en`, German pages `language: de`, and so on. Train monolingual or multilingual LoRAs by filtering the JSONL on the `language` field.
|
||||
|
||||
## Licence
|
||||
|
||||
The docs are licenced under the same BSD-4-Clause as the framework itself. The exported corpus inherits that licence — you're free to fine-tune models on it for commercial use, attribution-required.
|
||||
150
docs/src/content/docs/en/ai/module/agent.md
Normal file
150
docs/src/content/docs/en/ai/module/agent.md
Normal file
@@ -0,0 +1,150 @@
|
||||
---
|
||||
title: Agent plugin
|
||||
description: A ReAct-style tool-using agent. Extend Tool to give it any PHP capability you can write.
|
||||
---
|
||||
|
||||
The Agent plugin lets you give an LLM **the ability to act** — to call SQL queries, hit HTTP endpoints, read files, or do anything else you can express as a PHP method. It runs a ReAct-style loop: think → tool-call → observe → repeat → answer.
|
||||
|
||||
## Five lines, one agent
|
||||
|
||||
```php
|
||||
use Nibiru\Module\Ai\Ai;
|
||||
use Nibiru\Module\Ai\Plugin\Tools\PdoQuery;
|
||||
|
||||
$ai = new Ai();
|
||||
echo $ai->agent()
|
||||
->withTools([new PdoQuery()])
|
||||
->run('How many active users do we have?');
|
||||
// → "We have 1,247 active users." (after the agent ran SELECT count(*)…)
|
||||
```
|
||||
|
||||
## How it works
|
||||
|
||||
```
|
||||
user task
|
||||
↓
|
||||
LLM gets system prompt with tool definitions
|
||||
↓
|
||||
LLM emits ```tool {"tool":"pdo_query","args":{"sql":"SELECT…"}}```
|
||||
↓
|
||||
Agent runs the tool, captures result
|
||||
↓
|
||||
LLM gets observation, decides: more tools or final answer?
|
||||
↓
|
||||
"FINAL: 1,247 active users."
|
||||
```
|
||||
|
||||
The protocol uses a **fenced-JSON sentinel** — `\`\`\`tool {...}\`\`\`` — that any model can produce. No native tool-calling API required, so it works on every Ollama model out of the box. (Models that support native tool-calling can be plugged in via a subclass that overrides `parseToolCall()`.)
|
||||
|
||||
## Built-in tools
|
||||
|
||||
Nibiru ships three:
|
||||
|
||||
| Tool | What it does |
|
||||
|---|---|
|
||||
| `Tools\PdoQuery` | Single read-only `SELECT` against the app DB. Blocks INSERT/UPDATE/DELETE/DROP/TRUNCATE/ALTER. Returns up to 50 rows as JSON. |
|
||||
| `Tools\HttpGet` | GET an HTTP/HTTPS URL with optional headers. Returns body, truncated to 8 KB. |
|
||||
| `Tools\FileRead` | Read a project file by relative path. Blocks `..` traversal. Returns up to 8 KB. |
|
||||
|
||||
```php
|
||||
use Nibiru\Module\Ai\Plugin\Tools;
|
||||
|
||||
$agent = $ai->agent()->withTools([
|
||||
new Tools\PdoQuery(),
|
||||
new Tools\HttpGet(),
|
||||
new Tools\FileRead(),
|
||||
]);
|
||||
|
||||
// Multi-step task
|
||||
echo $agent->run(
|
||||
'Read application/controller/loginController.php and tell me '
|
||||
. 'whether it implements rate limiting.'
|
||||
);
|
||||
```
|
||||
|
||||
The agent will call `file_read` with the path, observe the source, and answer based on what it actually saw — not on what it imagines.
|
||||
|
||||
## Writing a custom tool
|
||||
|
||||
Extend `Tool`:
|
||||
|
||||
```php
|
||||
namespace App\AiTools;
|
||||
|
||||
use Nibiru\Module\Ai\Plugin\Tool;
|
||||
|
||||
class StripeRefund extends Tool
|
||||
{
|
||||
public function name(): string { return 'stripe_refund'; }
|
||||
|
||||
public function description(): string {
|
||||
return 'Issue a Stripe refund for a charge ID.';
|
||||
}
|
||||
|
||||
public function schema(): array {
|
||||
return [
|
||||
'charge_id' => [
|
||||
'type' => 'string',
|
||||
'description' => 'A Stripe charge ID, e.g. ch_3K…',
|
||||
'required' => true,
|
||||
],
|
||||
'amount_cents' => [
|
||||
'type' => 'integer',
|
||||
'description' => 'Amount to refund in cents. Omit for full refund.',
|
||||
'required' => false,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
public function execute(array $args): mixed {
|
||||
$stripe = new \Stripe\StripeClient(getenv('STRIPE_SECRET_KEY'));
|
||||
$refund = $stripe->refunds->create(array_filter([
|
||||
'charge' => $args['charge_id'],
|
||||
'amount' => $args['amount_cents'] ?? null,
|
||||
]));
|
||||
return json_encode([
|
||||
'refund_id' => $refund->id,
|
||||
'status' => $refund->status,
|
||||
'amount' => $refund->amount,
|
||||
]);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Then plug it in:
|
||||
|
||||
```php
|
||||
$ai->agent()
|
||||
->withTools([new \App\AiTools\StripeRefund(), new Tools\PdoQuery()])
|
||||
->run('Refund order #4421 — they were charged twice.');
|
||||
```
|
||||
|
||||
The agent will use `pdo_query` to find the charge, then call `stripe_refund` with that charge ID.
|
||||
|
||||
## Looking at the trace
|
||||
|
||||
```php
|
||||
$agent = $ai->agent()->withTools([new Tools\PdoQuery()]);
|
||||
$answer = $agent->run('How many products in the gold-plating category?');
|
||||
|
||||
foreach ($agent->trace() as $step) {
|
||||
echo "Step {$step['step']}: action={$step['action']}\n obs={$step['observation']}\n";
|
||||
}
|
||||
```
|
||||
|
||||
Useful for debugging, audit trails, or building a "show your work" UI.
|
||||
|
||||
## Safety
|
||||
|
||||
- **`PdoQuery` blocks writes.** If you want write access, write a more privileged subclass with an audit trail. Don't lift the SELECT-only restriction in the built-in tool.
|
||||
- **`HttpGet` allows any URL by default.** Lock down via an allowlist in `[AI] http_allowed_hosts[]` (planned), or write a `RestrictedHttpGet` subclass that filters URLs.
|
||||
- **`FileRead` blocks `..`.** It's confined to the application root.
|
||||
- **Max iterations.** `agent.max_iterations = 6` in the INI prevents runaway loops. Raise carefully.
|
||||
- **Tool timeout.** `agent.tool_timeout = 30` (seconds). A tool that hangs won't hold the request forever.
|
||||
|
||||
## Common pitfalls
|
||||
|
||||
- **Forgetting `withTools()`.** Without tools, the agent is just a regular `Chat`.
|
||||
- **Letting the agent see secrets.** Never put API keys, raw passwords, or PII into a tool's response — the model receives the full string.
|
||||
- **Long tool outputs.** Each observation is appended to the conversation. A tool that dumps 50 KB will exhaust context fast. The built-in tools cap at 8 KB; do the same in your custom tools.
|
||||
- **No tool-call in reply = final answer.** If the model produces a final answer that *looks like* a tool call but doesn't validate, the agent treats it as final. Be explicit in the prompt: "Output a tool call OR a final answer, never both."
|
||||
118
docs/src/content/docs/en/ai/module/chat.md
Normal file
118
docs/src/content/docs/en/ai/module/chat.md
Normal file
@@ -0,0 +1,118 @@
|
||||
---
|
||||
title: Chat plugin
|
||||
description: Single- or multi-turn chat completions against any Ollama-compatible endpoint.
|
||||
---
|
||||
|
||||
The Chat plugin is the simplest piece of the AI module. It wraps Ollama's `/api/chat` with a fluent builder, conversation memory, automatic fallback to a backup model, and a one-shot `ask()` shortcut.
|
||||
|
||||
## API at a glance
|
||||
|
||||
```php
|
||||
$ai = new \Nibiru\Module\Ai\Ai();
|
||||
$chat = $ai->chat();
|
||||
|
||||
$chat->system('Be terse.'); // optional system prompt
|
||||
$chat->model('qwen2.5-coder:14b'); // override the configured model
|
||||
$chat->temperature(0.2); // override config
|
||||
$chat->maxTokens(512); // override config
|
||||
|
||||
$chat->user('Hello'); // append a user message
|
||||
$chat->assistant('Hi.'); // append an assistant message (rare)
|
||||
|
||||
$reply = $chat->complete(); // run the call, return text
|
||||
$reply = $chat->ask('How are you?'); // = ->user(...)->complete()
|
||||
|
||||
$chat->reset(); // clear messages, keep model + system
|
||||
$chat->history(); // [{role, content}, …]
|
||||
```
|
||||
|
||||
## One-shot
|
||||
|
||||
```php
|
||||
echo (new \Nibiru\Module\Ai\Ai())
|
||||
->chat()
|
||||
->ask('In one sentence, what does Form::create() do?');
|
||||
```
|
||||
|
||||
## Multi-turn
|
||||
|
||||
```php
|
||||
$chat = $ai->chat();
|
||||
|
||||
$chat->user('Name three Nibiru singletons.');
|
||||
$singletons = $chat->complete(); // appended to history
|
||||
|
||||
$chat->user('What does the second one do?');
|
||||
$detail = $chat->complete(); // model has full context
|
||||
```
|
||||
|
||||
## Overriding model + style per call
|
||||
|
||||
```php
|
||||
$german = $ai->chat()
|
||||
->system('Answer in German. Be precise.')
|
||||
->model('qwen2.5-coder:14b')
|
||||
->temperature(0.1)
|
||||
->ask('Wie definiere ich einen Controller?');
|
||||
```
|
||||
|
||||
## Automatic fallback
|
||||
|
||||
If `chat.model` (e.g. `nibiru-coder:1.0`) isn't on the Ollama server, the plugin re-runs the call with `chat.fallback_model` (e.g. `qwen2.5-coder:14b`). This keeps your app working while you're still building the fine-tune.
|
||||
|
||||
```ini
|
||||
[AI]
|
||||
chat.model = "nibiru-coder:1.0"
|
||||
chat.fallback_model = "qwen2.5-coder:14b"
|
||||
```
|
||||
|
||||
## Switching providers
|
||||
|
||||
Default: Ollama. To use Anthropic Claude as the backend:
|
||||
|
||||
```ini
|
||||
[AI]
|
||||
chat.provider = "anthropic"
|
||||
anthropic.api_key = "sk-ant-..."
|
||||
anthropic.model = "claude-haiku-4-5-20251001"
|
||||
```
|
||||
|
||||
The Chat plugin doesn't ship Anthropic transport in the framework module yet — for now, the docs site's `scripts/lib/providers.mjs` pattern is the reference. (See the [Roadmap](/en/ai/roadmap/).)
|
||||
|
||||
## A practical pattern: chat-as-an-action
|
||||
|
||||
```php
|
||||
namespace Nibiru;
|
||||
use Nibiru\Adapter\Controller;
|
||||
use Nibiru\Module\Ai\Ai;
|
||||
|
||||
class supportController extends Controller
|
||||
{
|
||||
public function askAction(): void {
|
||||
View::forwardToJsonHeader();
|
||||
$q = trim($this->getPost('question', ''));
|
||||
if ($q === '') {
|
||||
View::assign(['data' => ['error' => 'question required']]);
|
||||
return;
|
||||
}
|
||||
$reply = (new Ai())->chat()
|
||||
->system('You are the Nibiru support assistant. Be brief.')
|
||||
->ask($q);
|
||||
View::assign(['data' => ['answer' => $reply]]);
|
||||
}
|
||||
|
||||
public function pageAction(): void {}
|
||||
public function navigationAction(): void {
|
||||
JsonNavigation::getInstance()->loadJsonNavigationArray();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Six lines plus boilerplate, and you've got an AJAX-callable AI endpoint backed by your own Ollama.
|
||||
|
||||
## Common pitfalls
|
||||
|
||||
- **Forgetting `complete()`.** The fluent builder doesn't run anything until `complete()` or `ask()` is called.
|
||||
- **Calling `assistant()` between user turns.** It's there for replaying a saved conversation, not for normal use.
|
||||
- **Long conversations.** Each turn re-sends the full history. Trim with `reset()` or by slicing `history()` when you no longer need older context.
|
||||
- **Setting temperature too low.** 0 makes the model rigid; 0.3–0.5 is the sweet spot for technical answers.
|
||||
99
docs/src/content/docs/en/ai/module/embed.md
Normal file
99
docs/src/content/docs/en/ai/module/embed.md
Normal file
@@ -0,0 +1,99 @@
|
||||
---
|
||||
title: Embed plugin
|
||||
description: Turn text into vectors. Cosine similarity. Compact storage. The plumbing under the RAG plugin, also useful on its own.
|
||||
---
|
||||
|
||||
The Embed plugin is a thin wrapper around Ollama's `/api/embeddings` plus three useful helpers: cosine similarity, compact base64 packing, and inverse unpacking. The [RAG plugin](/en/ai/module/rag/) uses it internally, but it's also useful on its own — for clustering, deduplication, semantic search, anomaly detection.
|
||||
|
||||
## API
|
||||
|
||||
```php
|
||||
$embed = (new \Nibiru\Module\Ai\Ai())->embed();
|
||||
|
||||
$vec = $embed->one('controller'); // float[]
|
||||
$vectors = $embed->batch(['a', 'b', 'c']); // float[][]
|
||||
|
||||
$score = \Nibiru\Module\Ai\Plugin\Embed::cosine($a, $b); // 0..1
|
||||
$packed = \Nibiru\Module\Ai\Plugin\Embed::pack($vec); // base64 string
|
||||
$vec = \Nibiru\Module\Ai\Plugin\Embed::unpack($packed); // back to float[]
|
||||
```
|
||||
|
||||
## Pattern: deduplicate near-duplicate strings
|
||||
|
||||
```php
|
||||
$embed = $ai->embed();
|
||||
$candidates = ['How do I create a module?',
|
||||
'How can I make a new module?',
|
||||
'What is MMVC?'];
|
||||
|
||||
$vecs = $embed->batch($candidates);
|
||||
foreach ($vecs as $i => $a) {
|
||||
foreach ($vecs as $j => $b) {
|
||||
if ($i >= $j) continue;
|
||||
$sim = \Nibiru\Module\Ai\Plugin\Embed::cosine($a, $b);
|
||||
if ($sim > 0.9) {
|
||||
echo "Near-dup: {$candidates[$i]} ≈ {$candidates[$j]} ($sim)\n";
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Pattern: semantic tagging
|
||||
|
||||
```php
|
||||
$tags = ['authentication', 'forms', 'database', 'modules'];
|
||||
$tagVecs = array_combine($tags, $embed->batch($tags));
|
||||
|
||||
function bestTag(string $text, array $tagVecs, $embed): string {
|
||||
$tv = $embed->one($text);
|
||||
$best = ['_unknown', -INF];
|
||||
foreach ($tagVecs as $tag => $vec) {
|
||||
$s = \Nibiru\Module\Ai\Plugin\Embed::cosine($tv, $vec);
|
||||
if ($s > $best[1]) $best = [$tag, $s];
|
||||
}
|
||||
return $best[0];
|
||||
}
|
||||
|
||||
echo bestTag('User::isAuthorized', $tagVecs, $embed); // → 'authentication'
|
||||
echo bestTag('Pageination::setTable', $tagVecs, $embed); // → 'database' (probably)
|
||||
```
|
||||
|
||||
## Storage
|
||||
|
||||
Embeddings are float arrays — typically 768 floats for `nomic-embed-text`, 1024 for `mxbai-embed-large`. That's 3 KB or 4 KB per vector raw.
|
||||
|
||||
Use `Embed::pack()` to base64-encode them as 4-byte floats:
|
||||
|
||||
```php
|
||||
$compact = Embed::pack($vec); // ~4 KB → ~5.3 KB base64 string
|
||||
$vec = Embed::unpack($compact);
|
||||
```
|
||||
|
||||
The RAG plugin uses this format internally for its JSON files.
|
||||
|
||||
## Embedding model choices
|
||||
|
||||
Pull on neuronetz.ai once:
|
||||
|
||||
```bash
|
||||
curl https://api.neuronetz.ai/api/pull -d '{"name":"nomic-embed-text"}' # 768 dim, default
|
||||
curl https://api.neuronetz.ai/api/pull -d '{"name":"mxbai-embed-large"}' # 1024 dim, higher quality
|
||||
```
|
||||
|
||||
In `ai.ini`:
|
||||
|
||||
```ini
|
||||
[AI]
|
||||
embed.model = "nomic-embed-text"
|
||||
embed.dim = 768
|
||||
```
|
||||
|
||||
:::caution[Don't mix models]
|
||||
Vectors from `nomic-embed-text` and `mxbai-embed-large` live in different geometric spaces — cosine between them is meaningless. If you change `embed.model`, **reset** any RAG collection or saved embeddings before mixing.
|
||||
:::
|
||||
|
||||
## Common pitfalls
|
||||
|
||||
- **Calling `one()` in a tight loop.** Each call is one HTTP round-trip. For >100 items, prefer `batch()` (still serial under the hood, but with consistent error handling).
|
||||
- **Storing raw float arrays in JSON.** Use `pack()` for ~5x smaller files and faster parse.
|
||||
- **Comparing cosine to a fixed threshold.** Different embedding models have different "similar" baselines. Don't hard-code 0.85 — calibrate per model.
|
||||
176
docs/src/content/docs/en/ai/module/overview.md
Normal file
176
docs/src/content/docs/en/ai/module/overview.md
Normal file
@@ -0,0 +1,176 @@
|
||||
---
|
||||
title: The AI module
|
||||
description: First-class AI in Nibiru — chat, embeddings, RAG, agents — wired to your own Ollama on neuronetz.ai. No paid APIs required.
|
||||
---
|
||||
|
||||
Nibiru ships an **AI module** (`application/module/ai/`) that gives every Nibiru app a first-class AI surface. PHP code can chat with a local LLM, embed text, run RAG over its own data, or run an agent with tools — all without sending a byte to a paid API.
|
||||
|
||||
The module is wired to your own [Ollama on neuronetz.ai](/en/ai/oracle/) by default, so inference is on your hardware, on your network, on your terms.
|
||||
|
||||
## What you get
|
||||
|
||||
| Plugin | What it does | One-liner |
|
||||
|---|---|---|
|
||||
| `Chat` | Chat completions, single- or multi-turn | `$ai->chat()->ask('…')` |
|
||||
| `Embed` | Text → vectors + cosine helpers | `$ai->embed()->one('…')` |
|
||||
| `Rag` | Ingest + retrieve + grounded chat | `$ai->rag('docs')->ask('…')` |
|
||||
| `Agent` | Tool-using ReAct loop | `$ai->agent()->withTools([…])->run('…')` |
|
||||
| `Tool` | Base for your own custom tools | `class MyTool extends Tool { … }` |
|
||||
| `Ollama` | Raw HTTP transport to any Ollama-compatible endpoint | `(new Ollama($cfg))->chat(…)` |
|
||||
|
||||
## Hello, AI
|
||||
|
||||
```php
|
||||
use Nibiru\Module\Ai\Ai;
|
||||
|
||||
$ai = new Ai();
|
||||
|
||||
echo $ai->chat()->ask('How do I scaffold a new module?');
|
||||
// → "Run `./nibiru -m <name>`. This creates application/module/<name>/ with…"
|
||||
```
|
||||
|
||||
That's the whole API surface for the simple case. No DI container, no API keys, no SDK install.
|
||||
|
||||
## Wired by config
|
||||
|
||||
Every plugin reads its settings from `application/module/ai/settings/ai.ini`:
|
||||
|
||||
```ini
|
||||
[AI]
|
||||
ollama.base_url = "https://api.neuronetz.ai"
|
||||
chat.model = "nibiru-coder:1.0"
|
||||
chat.fallback_model = "qwen2.5-coder:14b"
|
||||
chat.temperature = 0.4
|
||||
chat.max_tokens = 1024
|
||||
embed.model = "nomic-embed-text"
|
||||
rag.top_k = 6
|
||||
agent.max_iterations = 6
|
||||
```
|
||||
|
||||
Per-environment override: `ai.production.ini`, `ai.staging.ini`. Nibiru's [Registry](/en/core/registry/) auto-discovers them.
|
||||
|
||||
## The four flagship use cases
|
||||
|
||||
### 1. Chat — talk to your model
|
||||
|
||||
```php
|
||||
$ai = new \Nibiru\Module\Ai\Ai();
|
||||
|
||||
// One-shot
|
||||
echo $ai->chat()->ask('Explain MMVC in two sentences.');
|
||||
|
||||
// Multi-turn
|
||||
$chat = $ai->chat();
|
||||
$chat->user('How do I scaffold a module?');
|
||||
$chat->user('And add Graylog hooks?'); // referrs to previous turn
|
||||
echo $chat->complete();
|
||||
|
||||
// Override per call
|
||||
echo $ai->chat()
|
||||
->system('Answer in German.')
|
||||
->model('qwen2.5-coder:14b')
|
||||
->temperature(0.1)
|
||||
->ask('Was ist ein Modul?');
|
||||
```
|
||||
|
||||
The `Chat` plugin auto-falls back to `chat.fallback_model` if the primary model isn't available — useful while you're still building `nibiru-coder`.
|
||||
|
||||
### 2. Embed — text into vectors
|
||||
|
||||
```php
|
||||
$embed = $ai->embed();
|
||||
|
||||
$va = $embed->one('controller');
|
||||
$vb = $embed->one('module');
|
||||
$score = \Nibiru\Module\Ai\Plugin\Embed::cosine($va, $vb);
|
||||
// → 0.78 (close concepts)
|
||||
```
|
||||
|
||||
Compact storage:
|
||||
|
||||
```php
|
||||
$packed = \Nibiru\Module\Ai\Plugin\Embed::pack($vec); // base64 string, 4 bytes/dim
|
||||
$vec = \Nibiru\Module\Ai\Plugin\Embed::unpack($packed);
|
||||
```
|
||||
|
||||
### 3. RAG — ingest, retrieve, ground
|
||||
|
||||
```php
|
||||
$rag = $ai->rag('product-help');
|
||||
|
||||
// One-time ingestion
|
||||
$rag->ingestDir(__DIR__ . '/help/'); // walks .md/.txt/.php
|
||||
$rag->ingestText('FAQ entry…', ['source' => 'faq-12']);
|
||||
$rag->ingestFile('/var/data/manual.pdf.txt');
|
||||
|
||||
// Then ask grounded questions
|
||||
echo $rag->ask('How do I cancel my subscription?');
|
||||
// → "Per the help docs, you can cancel in account → settings… [1]"
|
||||
```
|
||||
|
||||
Storage: a single JSON file per collection at `application/module/ai/cache/rag/<name>.json`. Restartable, zero DB, fits ~10k chunks comfortably in memory.
|
||||
|
||||
### 4. Agent — tools that act
|
||||
|
||||
```php
|
||||
use Nibiru\Module\Ai\Plugin\Tools;
|
||||
|
||||
$ai = new \Nibiru\Module\Ai\Ai();
|
||||
|
||||
$agent = $ai->agent()->withTools([
|
||||
new Tools\PdoQuery(), // read-only SQL
|
||||
new Tools\HttpGet(), // fetch URLs
|
||||
new Tools\FileRead(), // read project files
|
||||
]);
|
||||
|
||||
echo $agent->run('How many active users registered last week?');
|
||||
// → agent decides to call pdo_query with SELECT count(*) FROM users…
|
||||
// reads observation, writes a final answer.
|
||||
```
|
||||
|
||||
The agent uses a ReAct-style loop: read task → pick tool → execute → observe → repeat → final answer. The protocol uses a simple `\`\`\`tool {...}\`\`\`` JSON sentinel that works on every Ollama model — no model-specific tool-calling APIs required.
|
||||
|
||||
## Where it lives
|
||||
|
||||
```
|
||||
application/module/ai/
|
||||
├── ai.php # main class implementing IModule
|
||||
├── interfaces/ai.php # contract
|
||||
├── traits/ai.php # cfg() helper
|
||||
├── plugins/
|
||||
│ ├── ollama.php # raw transport
|
||||
│ ├── chat.php # chat completions
|
||||
│ ├── embed.php # embeddings + cosine + pack
|
||||
│ ├── rag.php # ingest + retrieve + grounded chat
|
||||
│ ├── agent.php # ReAct tool loop
|
||||
│ ├── tool.php # abstract base for custom tools
|
||||
│ └── tools/
|
||||
│ ├── pdoQuery.php # read-only SQL
|
||||
│ ├── httpGet.php # HTTP GET
|
||||
│ └── fileRead.php # project-local file read
|
||||
├── settings/ai.ini # config
|
||||
├── cache/rag/ # RAG vector index files (gitignored)
|
||||
└── training/
|
||||
├── Modelfile # the nibiru-coder system prompt
|
||||
├── build.sh # one-command Modelfile → registered model
|
||||
├── smoke-test.php # verify the whole stack
|
||||
└── README.md # training pipeline guide
|
||||
```
|
||||
|
||||
## Why this exists
|
||||
|
||||
PHP doesn't have an established "AI framework" the way Python has LangChain or JS has Vercel AI SDK. Nibiru's AI module fills that gap with the smallest, sharpest API we could write — three layers (transport → plugin → module), no DI graph, no SDK install, no per-token bill.
|
||||
|
||||
The design philosophy:
|
||||
|
||||
- **Bring your own brain.** Ollama by default, Anthropic and OpenAI as drop-ins. Swap providers via INI, never via code.
|
||||
- **One JSON file per RAG collection.** No vector DB. Reboot-safe. Grep-able when you're debugging.
|
||||
- **Tools are PHP classes.** Extend `Tool`, get a name + schema + execute method. The agent figures out the rest.
|
||||
- **No model-specific tool-call APIs.** A single fenced-JSON convention works everywhere.
|
||||
|
||||
## Next
|
||||
|
||||
- [Chat plugin reference](/en/ai/module/chat/)
|
||||
- [RAG plugin reference](/en/ai/module/rag/)
|
||||
- [Agent plugin reference](/en/ai/module/agent/)
|
||||
- [Training nibiru-coder](/en/ai/module/training/)
|
||||
109
docs/src/content/docs/en/ai/module/rag.md
Normal file
109
docs/src/content/docs/en/ai/module/rag.md
Normal file
@@ -0,0 +1,109 @@
|
||||
---
|
||||
title: RAG plugin
|
||||
description: Ingest text, embed it, retrieve top-K, and answer grounded questions — all in one PHP class.
|
||||
---
|
||||
|
||||
The RAG plugin is the AI module's killer feature for product builders. It turns any pile of text — your help docs, your error logs, your Stripe invoices, your customer-support tickets — into a queryable knowledge base in roughly four lines of PHP.
|
||||
|
||||
## Three minutes, end-to-end
|
||||
|
||||
```php
|
||||
use Nibiru\Module\Ai\Ai;
|
||||
|
||||
$ai = new Ai();
|
||||
$rag = $ai->rag('product-help'); // a named collection
|
||||
|
||||
$rag->ingestDir(__DIR__ . '/help/'); // walks .md/.txt/.php under help/
|
||||
$rag->ingestText('FAQ entry…', ['source' => 'faq-12']);
|
||||
|
||||
echo $rag->ask('How do I cancel my subscription?');
|
||||
// → grounded answer, citing chunks like [1] [2] [3]
|
||||
```
|
||||
|
||||
That's it. No vector DB. No SDK. No Python sidecar.
|
||||
|
||||
## How it works
|
||||
|
||||
```
|
||||
ingestText / ingestFile / ingestDir
|
||||
↓
|
||||
chunk → embed (Ollama nomic-embed-text)
|
||||
↓
|
||||
pack vectors → JSON file at cache/rag/<collection>.json
|
||||
↓
|
||||
ask(question) → embed question → cosine top-K → chat with chunks as context
|
||||
```
|
||||
|
||||
Storage is one JSON file per collection. Each chunk is an object with `text` + `metadata`; vectors are base64-packed Float32Array — about 3 KB per chunk. ~10k chunks fits comfortably in memory.
|
||||
|
||||
## Multiple collections
|
||||
|
||||
You can have any number of collections in the same app. Each has its own JSON file. They share embedding model and chat model from `[AI]` config.
|
||||
|
||||
```php
|
||||
$docs = $ai->rag('docs');
|
||||
$tickets = $ai->rag('support-tickets');
|
||||
$logs = $ai->rag('error-logs');
|
||||
|
||||
$docs->ingestDir(__DIR__ . '/help/');
|
||||
$tickets->ingestText($ticket->body, ['ticket_id' => $ticket->id]);
|
||||
$logs->ingestText($exception->__toString(), ['ts' => time()]);
|
||||
```
|
||||
|
||||
## API reference
|
||||
|
||||
```php
|
||||
$rag = $ai->rag('name'); // get/create a named collection
|
||||
|
||||
// --- Ingestion ---
|
||||
$rag->ingestText($text, $metadata = []); // single chunk
|
||||
$count = $rag->ingestFile('path'); // returns chunks added
|
||||
$count = $rag->ingestDir('dir', ['md','txt','php']); // recursive
|
||||
|
||||
// --- Querying ---
|
||||
$hits = $rag->search('query', $k = null); // [{score, text, metadata}, …]
|
||||
$answer = $rag->ask('question', $k = null); // top-K → chat call
|
||||
|
||||
// --- Maintenance ---
|
||||
$rag->reset(); // forget everything (deletes file)
|
||||
$n = $rag->size(); // number of chunks
|
||||
```
|
||||
|
||||
## Tuning knobs
|
||||
|
||||
In `application/module/ai/settings/ai.ini`:
|
||||
|
||||
```ini
|
||||
[AI]
|
||||
embed.model = "nomic-embed-text" ; or mxbai-embed-large for higher quality
|
||||
rag.top_k = 6 ; chunks injected into the chat call
|
||||
rag.chunk_target = 600 ; tokens per chunk (target)
|
||||
rag.chunk_min = 120 ; smaller chunks merged
|
||||
rag.chunk_max = 900 ; larger paragraphs split on sentences
|
||||
rag.storage_path = "/../../application/module/ai/cache/rag/"
|
||||
```
|
||||
|
||||
## When to use it
|
||||
|
||||
- **Help / FAQ chat** — ingest your help articles, expose a `/ask` endpoint.
|
||||
- **In-app code search** — ingest `application/module/`, ask "where do we calculate VAT?"
|
||||
- **Internal docs assistant** — ingest your team's wiki dump.
|
||||
- **Customer-history lookups** — ingest tickets, ask "have we seen this error before?"
|
||||
|
||||
## When NOT to use it
|
||||
|
||||
- **Real-time, write-heavy data** — RAG is a snapshot. For live data, write a [Tool](/en/ai/module/agent/) the agent can call.
|
||||
- **Massive corpora (> 100k chunks)** — JSON-file storage starts to creak. Move to Qdrant / pgvector / Weaviate; we'll publish an adapter once we need one ourselves.
|
||||
- **Anything where you need *exact* answers, not *probable* ones.** RAG is probabilistic. Don't use it as a database query layer.
|
||||
|
||||
## Common pitfalls
|
||||
|
||||
- **`nomic-embed-text` not pulled.** The first `ingestText` call will fail with a clear error pointing you at the pull command.
|
||||
- **Embedding model mismatch.** Don't mix `nomic-embed-text` chunks with `mxbai-embed-large` queries — different vector spaces. If you change `embed.model`, run `$rag->reset()` first.
|
||||
- **Stale collections.** Re-running ingestDir doesn't dedupe. Use `reset()` then re-ingest, or maintain a content-hash check yourself.
|
||||
- **Tiny chunks.** Below ~80 tokens, embeddings get noisy. The default `rag.chunk_min = 120` merges small adjacent chunks.
|
||||
|
||||
## What's next
|
||||
|
||||
- [Agent plugin →](/en/ai/module/agent/) for tools, not retrieval.
|
||||
- [Training nibiru-coder →](/en/ai/module/training/) to make the chat half answer in the framework's voice.
|
||||
123
docs/src/content/docs/en/ai/module/training.md
Normal file
123
docs/src/content/docs/en/ai/module/training.md
Normal file
@@ -0,0 +1,123 @@
|
||||
---
|
||||
title: Training nibiru-coder
|
||||
description: How to register a Nibiru-flavoured chat model on your own Ollama. One Modelfile, one shell script, sixty seconds.
|
||||
---
|
||||
|
||||
The framework's default chat model is **`nibiru-coder:1.0`** — a Nibiru-flavoured Qwen 2.5 Coder 14B that you register on your Ollama server. The training pipeline lives in `application/module/ai/training/`.
|
||||
|
||||
## What `nibiru-coder` is (and isn't)
|
||||
|
||||
`nibiru-coder:1.0` is **not** a LoRA fine-tune. It's the same `qwen2.5-coder:14b` weights wrapped with a baked-in system prompt that:
|
||||
|
||||
- explains MMVC, modules, the dispatcher, the singletons,
|
||||
- enforces Nibiru's conventions (`pageAction`, `navigationAction`, `View::assign`, `Form::create`, the spelling of `Pageination`),
|
||||
- pushes the model toward Nibiru-idiomatic answers instead of generic Laravel / Symfony advice.
|
||||
|
||||
System-prompt customisation runs **instantly** — no GPU training, no dataset preparation. It gives roughly 80 % of the value of a real LoRA at zero training cost. When you have budget for a real fine-tune, see *Real LoRA path* below.
|
||||
|
||||
## Build it
|
||||
|
||||
```bash
|
||||
./application/module/ai/training/build.sh # builds nibiru-coder:1.0
|
||||
./application/module/ai/training/build.sh 1.1 # bump tag for iterations
|
||||
```
|
||||
|
||||
The script:
|
||||
|
||||
1. Reads the Modelfile next to it.
|
||||
2. POSTs to `${OLLAMA_BASE_URL}/api/create` (default `https://api.neuronetz.ai`).
|
||||
3. Runs a smoke-test chat call to confirm the new tag responds.
|
||||
|
||||
After it succeeds, set the model in `application/module/ai/settings/ai.ini`:
|
||||
|
||||
```ini
|
||||
[AI]
|
||||
chat.model = "nibiru-coder:1.0"
|
||||
chat.fallback_model = "qwen2.5-coder:14b"
|
||||
```
|
||||
|
||||
…and every `\Nibiru\Module\Ai\Ai` instance in your app talks to it. The fallback ensures nothing breaks if you've not built the tag yet.
|
||||
|
||||
## Iterate on the system prompt
|
||||
|
||||
The Modelfile's `SYSTEM """ ... """` block is the lever. Tighten the conventions, add new examples, add citations to specific framework files. Re-run `build.sh` with a new tag (`1.1`, `1.2`, …) and A/B against the previous tag in your app.
|
||||
|
||||
```bash
|
||||
./application/module/ai/training/build.sh 1.1
|
||||
# Edit ai.ini → chat.model = "nibiru-coder:1.1"
|
||||
# Compare answers in the Oracle widget or via smoke-test.php
|
||||
```
|
||||
|
||||
## Real LoRA path
|
||||
|
||||
When you want a model whose **weights** know Nibiru — not just its system prompt — the corpus exporter has you covered.
|
||||
|
||||
```bash
|
||||
cd docs
|
||||
npm run build:corpus
|
||||
```
|
||||
|
||||
Outputs JSONL files under `dist/corpus/`:
|
||||
|
||||
| File | Format | Use |
|
||||
|---|---|---|
|
||||
| `chat.jsonl` | sharegpt-style messages | Axolotl, LLaMA-Factory, Unsloth |
|
||||
| `instructions.jsonl` | instruction/input/output | Alpaca-style trainers |
|
||||
| `completion.jsonl` | prompt/completion | Legacy text-completion fine-tunes |
|
||||
| `chunks.jsonl` | chunk metadata | RAG / evaluation set construction |
|
||||
|
||||
A pragmatic recipe for an 8B base on a single A100 / 4090:
|
||||
|
||||
```yaml
|
||||
# axolotl.yml
|
||||
base_model: meta-llama/Llama-3.1-8B-Instruct
|
||||
adapter: lora
|
||||
lora_r: 16
|
||||
lora_alpha: 32
|
||||
lora_dropout: 0.05
|
||||
lora_target_modules: [q_proj, k_proj, v_proj, o_proj]
|
||||
|
||||
datasets:
|
||||
- path: docs/dist/corpus/chat.jsonl
|
||||
type: sharegpt
|
||||
|
||||
sequence_len: 4096
|
||||
sample_packing: true
|
||||
gradient_accumulation_steps: 4
|
||||
micro_batch_size: 2
|
||||
num_epochs: 3
|
||||
optimizer: adamw_bnb_8bit
|
||||
learning_rate: 0.0002
|
||||
warmup_ratio: 0.05
|
||||
bf16: true
|
||||
```
|
||||
|
||||
After training:
|
||||
|
||||
1. Convert the LoRA to GGUF (`llama.cpp`'s `convert_hf_to_gguf.py`).
|
||||
2. Build an Ollama Modelfile with `FROM ./your-lora.gguf`.
|
||||
3. `./build.sh 2.0` registers it as `nibiru-coder:2.0`.
|
||||
|
||||
The framework code doesn't change — flip `chat.model` in `ai.ini` and you're on the new weights.
|
||||
|
||||
## Smoke test
|
||||
|
||||
```bash
|
||||
php application/module/ai/training/smoke-test.php
|
||||
```
|
||||
|
||||
Verifies:
|
||||
|
||||
- The Ollama server is reachable.
|
||||
- The model responds to a single-turn ask.
|
||||
- Multi-turn conversation context works.
|
||||
- Embeddings work (skipped with a clear message if `nomic-embed-text` isn't pulled).
|
||||
|
||||
Run after every Modelfile change, before deploying.
|
||||
|
||||
## Common pitfalls
|
||||
|
||||
- **`Modelfile` system prompt too long.** Some Ollama versions cap system prompts. Keep it under ~3000 tokens.
|
||||
- **Forgetting to pull the FROM model.** `qwen2.5-coder:14b` must already be on the server. `curl ${OLLAMA_BASE_URL}/api/tags` to check.
|
||||
- **Tag collisions.** Re-running `build.sh 1.0` overwrites the existing `nibiru-coder:1.0`. Use new tags for iteration; pin specific tags in `ai.ini` for production.
|
||||
- **`--no-stream` confusion.** The build script uses `stream: false` so the response comes back as one JSON. If you change to streamed, parse line-by-line.
|
||||
120
docs/src/content/docs/en/ai/oracle.md
Normal file
120
docs/src/content/docs/en/ai/oracle.md
Normal file
@@ -0,0 +1,120 @@
|
||||
---
|
||||
title: Ask the Oracle
|
||||
description: How the in-site AI assistant works — RAG over the docs, served by your own Ollama on neuronetz.ai.
|
||||
---
|
||||
|
||||
The amber button in the corner of every page is the **Nibiru Oracle** — an AI assistant grounded in this very documentation. Ask it about routing, modules, the CLI, the Smarty layer, the meaning of `pageAction()`. It cites its sources.
|
||||
|
||||
## What's powering it
|
||||
|
||||
By default, the Oracle runs entirely on **your own infrastructure**:
|
||||
|
||||
| Layer | Backend | Default model |
|
||||
|---|---|---|
|
||||
| Chat (answer generation) | Ollama on `https://api.neuronetz.ai` | `qwen2.5-coder:14b` |
|
||||
| Embeddings (RAG retrieval) | Ollama on `https://api.neuronetz.ai` | `nomic-embed-text` |
|
||||
|
||||
No paid API keys. No data leaves your network. The 5-GPU Ollama cluster you already run handles the load.
|
||||
|
||||
If you'd rather use a paid provider — Claude for chat, OpenAI for embeddings — set `LLM_PROVIDER=anthropic` and/or `EMBED_PROVIDER=openai` plus the matching API keys. The code paths are identical.
|
||||
|
||||
## How it works
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[User question] --> B[Embed via Ollama<br/>nomic-embed-text]
|
||||
B --> C[Cosine search<br/>against pre-computed<br/>doc-chunk index]
|
||||
C --> D[Top-K chunks]
|
||||
D --> E[Ollama chat<br/>qwen2.5-coder:14b<br/>system + retrieved context]
|
||||
E --> F[Answer + source list]
|
||||
F --> G[Render in chat UI]
|
||||
```
|
||||
|
||||
1. **At build time**, the docs site walks every Markdown page, splits it into ~600-token chunks at H2/H3 boundaries, embeds each chunk with `nomic-embed-text`, and writes the result to `public/oracle-index.json`. No database needed.
|
||||
2. **At request time**, the user's question is embedded the same way, the closest chunks are retrieved by cosine similarity, and they're stitched into a system prompt for the chat model.
|
||||
3. **The chat model answers** in the user's language, citing source chunks by URL.
|
||||
|
||||
## Files
|
||||
|
||||
| File | Purpose |
|
||||
|---|---|
|
||||
| `scripts/lib/providers.mjs` | Shared chat + embedding adapter (Ollama / Anthropic / OpenAI). |
|
||||
| `scripts/build-oracle-index.mjs` | Builds `public/oracle-index.json` at build time. |
|
||||
| `public/oracle-index.json` | The committed/build-output embedding index. |
|
||||
| `src/pages/api/oracle.ts` | The SSR endpoint the chat widget POSTs to. Also serves a GET for diagnostics. |
|
||||
| `src/components/CosmicHeader.astro` | The floating launcher + chat UI. |
|
||||
|
||||
## One-time setup on neuronetz.ai
|
||||
|
||||
Pull the two models the Oracle uses:
|
||||
|
||||
```bash
|
||||
curl https://api.neuronetz.ai/api/pull -d '{"name":"qwen2.5-coder:14b"}'
|
||||
curl https://api.neuronetz.ai/api/pull -d '{"name":"nomic-embed-text"}'
|
||||
```
|
||||
|
||||
`qwen2.5-coder:14b` is already installed (verified live). `nomic-embed-text` is the missing piece; without it the Oracle runs in chat-only (no-RAG) mode.
|
||||
|
||||
## Configuring it
|
||||
|
||||
The Oracle reads its config from environment variables. Sensible defaults are baked in.
|
||||
|
||||
```bash
|
||||
# Default mode (Ollama on neuronetz.ai)
|
||||
LLM_PROVIDER=ollama # default
|
||||
OLLAMA_BASE_URL=https://api.neuronetz.ai # default
|
||||
OLLAMA_CHAT_MODEL=qwen2.5-coder:14b # default
|
||||
OLLAMA_EMBED_MODEL=nomic-embed-text # default
|
||||
|
||||
# Optional fallbacks
|
||||
LLM_PROVIDER=anthropic
|
||||
ANTHROPIC_API_KEY=sk-ant-...
|
||||
ANTHROPIC_MODEL=claude-haiku-4-5-20251001
|
||||
|
||||
EMBED_PROVIDER=openai
|
||||
OPENAI_API_KEY=sk-...
|
||||
OPENAI_EMBED_MODEL=text-embedding-3-small
|
||||
|
||||
# Behaviour
|
||||
ORACLE_TOP_K=6
|
||||
ORACLE_MAX_TOKENS=800
|
||||
```
|
||||
|
||||
## Diagnostics endpoint
|
||||
|
||||
`GET /api/oracle` returns the current config (no secrets):
|
||||
|
||||
```bash
|
||||
curl https://nibiru-framework.com/api/oracle
|
||||
{
|
||||
"status": "ok",
|
||||
"llm": { "provider": "ollama", "ollamaUrl": "https://api.neuronetz.ai",
|
||||
"model": "qwen2.5-coder:14b" },
|
||||
"embed": { "provider": "ollama", "ollamaUrl": "https://api.neuronetz.ai",
|
||||
"model": "nomic-embed-text" },
|
||||
"index": { "present": true, "chunks": 177,
|
||||
"provider": "ollama", "model": "nomic-embed-text" }
|
||||
}
|
||||
```
|
||||
|
||||
Handy for verifying a freshly-deployed container is using the backend you expected.
|
||||
|
||||
## Privacy
|
||||
|
||||
- Questions and conversation history are sent to your Ollama server. They are **not** stored by the docs site or by Anthropic/OpenAI in the default Ollama config.
|
||||
- The OpenAI key (if used) is invoked only for embeddings.
|
||||
- No analytics or cookies are set by the Oracle widget itself.
|
||||
|
||||
## Why a Nibiru-trained model?
|
||||
|
||||
The roadmap (see [AI Roadmap](/en/ai/roadmap/)) is to fine-tune a LoRA on the [Training Corpus](/en/ai/corpus/) export so the chat model itself is Nibiru-native. When that's ready, the Oracle's `OLLAMA_CHAT_MODEL` flips to the fine-tuned model and the system prompt simplifies. Same code, smarter answers.
|
||||
|
||||
## Try it
|
||||
|
||||
Open the Oracle (the amber planet, bottom-right) and try one of these:
|
||||
|
||||
- *"How do I create a new module?"*
|
||||
- *"What does `pageAction` do?"*
|
||||
- *"Show me how to handle a JSON endpoint."*
|
||||
- *"Wie schreibe ich eine Migration?"* (German works.)
|
||||
- *"認証フローを教えて"* (Japanese works.)
|
||||
55
docs/src/content/docs/en/ai/roadmap.md
Normal file
55
docs/src/content/docs/en/ai/roadmap.md
Normal file
@@ -0,0 +1,55 @@
|
||||
---
|
||||
title: AI Roadmap
|
||||
description: Where Nibiru's AI integration is going — the plan that gets us from RAG-grounded Oracle to a fine-tuned LoRA in production.
|
||||
---
|
||||
|
||||
Nibiru's ambition: be the **first PHP framework with a fine-tuned model trained on its own knowledge**, served as a first-class part of the dev experience. This page tracks the steps.
|
||||
|
||||
## Phase 1 — Today: RAG Oracle ✓
|
||||
|
||||
- [x] Markdown chunker with H2/H3 boundaries.
|
||||
- [x] OpenAI embeddings (`text-embedding-3-small`).
|
||||
- [x] Vector index as a single JSON file.
|
||||
- [x] Astro endpoint `/api/oracle` calling Claude with retrieved context.
|
||||
- [x] Floating chat widget on every doc page.
|
||||
- [x] Multilingual (EN/DE/JA/ES/FR) input + output.
|
||||
|
||||
**Why first.** RAG works without training, scales linearly with content size, and is dirt cheap. Every doc edit improves answer quality the same hour.
|
||||
|
||||
## Phase 2 — Next: Public corpus + LoRA recipe
|
||||
|
||||
- [ ] `npm run build:corpus` ships in `main` (instructions/chat/chunks JSONL).
|
||||
- [ ] Published Hugging Face dataset (`nibiru-framework/docs-corpus`).
|
||||
- [ ] Reference Axolotl YAML for Llama 3.1 8B.
|
||||
- [ ] Reference recipe for Qwen 2.5 7B and Mistral Nemo 12B.
|
||||
- [ ] Eval set: 200 hand-graded Nibiru questions with golden answers.
|
||||
|
||||
**Why second.** Once the corpus is reproducible from docs, anyone can train. We treat docs as the source of truth and the corpus as a derivative artifact.
|
||||
|
||||
## Phase 3 — Then: Hosted LoRA endpoint
|
||||
|
||||
- [ ] Train a first-pass LoRA on the public corpus.
|
||||
- [ ] Serve via vLLM behind `/api/oracle` with a feature flag.
|
||||
- [ ] Side-by-side UI comparing Claude (RAG) vs LoRA (no RAG) vs LoRA + RAG.
|
||||
- [ ] Telemetry: which form does the user prefer per question type?
|
||||
|
||||
**Why third.** Side-by-side comparison reveals where the LoRA helps (idiomatic Nibiru style) and where it hurts (very long context, fresh edits not yet retrained).
|
||||
|
||||
## Phase 4 — Eventually: Editor agents
|
||||
|
||||
- [ ] PHPStorm plugin: highlight a controller, ask the Oracle to convert it to a module.
|
||||
- [ ] CLI agent: `./nibiru ask "rewrite this controller as a JSON endpoint"`.
|
||||
- [ ] PR review bot: explain Nibiru-specific deviations in pull requests on framework forks.
|
||||
|
||||
## Phase 5 — Aspirational: Active learning
|
||||
|
||||
- [ ] User feedback in the chat widget (👍 / 👎) writes a row to a private dataset.
|
||||
- [ ] Weekly review queue surfaces low-rated answers for human annotation.
|
||||
- [ ] Improved answers re-enter the corpus on the next training cycle.
|
||||
|
||||
## How to help
|
||||
|
||||
- **Ask the Oracle hard questions** and rate the answers.
|
||||
- **Open issues** on the [GitHub repo](https://github.com/alllinux/Nibiru) for missing topics.
|
||||
- **Contribute translations** — every translated doc page is also a parallel corpus row.
|
||||
- **Try a LoRA fine-tune** on the published corpus and share results.
|
||||
65
docs/src/content/docs/en/cli/cms.md
Normal file
65
docs/src/content/docs/en/cli/cms.md
Normal file
@@ -0,0 +1,65 @@
|
||||
---
|
||||
title: CMS Pages (CLI)
|
||||
description: Create and delete CMS pages from the command line.
|
||||
---
|
||||
|
||||
When the `cms` module is installed, the Nibiru CLI gains two extra flags for managing CMS pages directly. This is the same content store powering the production e-commerce site `prod.maschinen-stockert.de`, where editors update site copy without touching code.
|
||||
|
||||
## Create a page
|
||||
|
||||
```bash
|
||||
./nibiru -new-cms-page about-us
|
||||
```
|
||||
|
||||
This:
|
||||
|
||||
1. Inserts a row in the `cms_pages` table with slug `about-us`.
|
||||
2. Binds the page to a CMS template (the default template, unless one is configured).
|
||||
3. Creates per-language placeholder rows in `cms_template_texts` so editors can fill in copy in every supported language.
|
||||
|
||||
Visit `/cms/about-us` (or your configured CMS prefix) and the new page is live.
|
||||
|
||||
## Delete a page
|
||||
|
||||
```bash
|
||||
./nibiru -delete-cms-page about-us
|
||||
```
|
||||
|
||||
Removes the page row and its associated `cms_template_texts` entries. The CMS template itself is not deleted — only the page binding to it.
|
||||
|
||||
## Why these are CLI commands and not just SQL
|
||||
|
||||
Two reasons:
|
||||
|
||||
1. **Atomicity** — creating a page requires inserts in two tables (the page and its text rows). The CLI wraps this in a transaction.
|
||||
2. **Slug uniqueness** — the CLI checks for collisions before inserting and gives a friendlier error than a SQL constraint violation.
|
||||
|
||||
## Without the CMS module
|
||||
|
||||
`-new-cms-page` and `-delete-cms-page` exit non-zero with a clear error message if the `cms` module isn't installed. Add it with:
|
||||
|
||||
```bash
|
||||
./nibiru -m cms
|
||||
./nibiru -mi local
|
||||
```
|
||||
|
||||
(See [Modules](/core/modules/) for what `./nibiru -m` does and the migration files the `cms` module ships.)
|
||||
|
||||
## Editing content after creation
|
||||
|
||||
The CLI doesn't edit text — that's deliberately left to the CMS module's web UI. From production code:
|
||||
|
||||
```php
|
||||
// Read all text identifiers for a controller path + language
|
||||
$texts = \Nibiru\Module\Cms\Cms::init('about-us')
|
||||
->loadCmsTemplateTextsByControllerPath('about-us/page', $this->language);
|
||||
|
||||
foreach ($texts as $t) {
|
||||
\Nibiru\View::assign([
|
||||
$t['cms_template_texts_text_identifier']
|
||||
=> $t['cms_template_texts_text_content']
|
||||
]);
|
||||
}
|
||||
```
|
||||
|
||||
The result: every `{$identifier}` in the template is auto-populated with the current language's content. Non-developers manage text via the admin UI; developers manage layout via the template.
|
||||
120
docs/src/content/docs/en/cli/migrations.md
Normal file
120
docs/src/content/docs/en/cli/migrations.md
Normal file
@@ -0,0 +1,120 @@
|
||||
---
|
||||
title: Migrations
|
||||
description: Numbered SQL migrations driven by the Nibiru CLI.
|
||||
---
|
||||
|
||||
Migrations are flat SQL files in `application/settings/config/database/`, executed in numeric order. The CLI tracks which files have run and skips them on subsequent invocations.
|
||||
|
||||
## File naming
|
||||
|
||||
```
|
||||
NNN-<slug>.sql
|
||||
```
|
||||
|
||||
- `NNN`: zero-padded three-digit number for sort order.
|
||||
- `<slug>`: kebab-case description (`add-account-email`, `create-acl-data`).
|
||||
|
||||
Example progression:
|
||||
|
||||
```
|
||||
001-acl.sql
|
||||
002-account.sql
|
||||
003-api_registry.sql
|
||||
004-timeanddate.sql
|
||||
005-user.sql
|
||||
006-user_to_account.sql
|
||||
007-timeanddate_to_account.sql
|
||||
008-user_to_acl.sql
|
||||
009-account_to_api_registry.sql
|
||||
010-timeanddate_to_user.sql
|
||||
011-acl-data.sql
|
||||
012-add-unique-key-user.sql
|
||||
013-add-account-email.sql
|
||||
```
|
||||
|
||||
## Running migrations
|
||||
|
||||
```bash
|
||||
./nibiru -mi local # APPLICATION_ENV=development
|
||||
APPLICATION_ENV=staging ./nibiru -mi staging
|
||||
APPLICATION_ENV=production ./nibiru -mi production
|
||||
```
|
||||
|
||||
The `{env}` argument and `APPLICATION_ENV` should agree. Migrations target the database configured in `settings.<env>.ini` under `[DATABASE]`.
|
||||
|
||||
## What the runner does
|
||||
|
||||
For each `*.sql` file in numeric order:
|
||||
|
||||
1. Looks up its filename in the `_migrations` table.
|
||||
2. If absent, opens a transaction (where the driver supports DDL transactions), executes the file, and inserts a record on success.
|
||||
3. If a statement fails, rolls back and exits non-zero.
|
||||
|
||||
The `_migrations` table is created automatically on first run.
|
||||
|
||||
## Idempotent SQL
|
||||
|
||||
Always write migrations that can re-run without errors after a partial failure:
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS api_registry (
|
||||
api_registry_id INT(11) NOT NULL AUTO_INCREMENT,
|
||||
api_registry_name VARCHAR(255) NOT NULL,
|
||||
PRIMARY KEY (api_registry_id),
|
||||
UNIQUE KEY api_registry_name_uk (api_registry_name)
|
||||
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4;
|
||||
```
|
||||
|
||||
For PostgreSQL:
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS api_registry (
|
||||
api_registry_id SERIAL PRIMARY KEY,
|
||||
api_registry_name TEXT NOT NULL UNIQUE
|
||||
);
|
||||
```
|
||||
|
||||
For ALTER statements, prefer `IF NOT EXISTS` clauses on supported engines:
|
||||
|
||||
```sql
|
||||
ALTER TABLE "user" ADD COLUMN IF NOT EXISTS user_email VARCHAR(255);
|
||||
```
|
||||
|
||||
If your engine doesn't support `IF NOT EXISTS` on alter, wrap the statement in a guard query that no-ops when the change already exists.
|
||||
|
||||
## Reset commands
|
||||
|
||||
:::caution
|
||||
The reset commands clear the migrations audit table — they don't drop your data tables. After a reset, the next `-mi` will re-run *every* migration. Make sure your SQL is idempotent before you reset.
|
||||
:::
|
||||
|
||||
```bash
|
||||
./nibiru -mi-reset local # forget all applied migrations
|
||||
./nibiru -mi-reset-file 005-user.sql local # forget a single file
|
||||
```
|
||||
|
||||
The single-file form is useful when you've fixed a bug in a previously-applied migration and want to re-run only that file.
|
||||
|
||||
## Branch hygiene
|
||||
|
||||
Two engineers working on parallel branches can both add `015-…` and collide. Conventions that help:
|
||||
|
||||
- **Reserve numbers in pull-request titles** before writing the SQL.
|
||||
- **Use a generous gap** for hotfixes (e.g., reserve `099`, `199`, `299` for emergency cherry-picks).
|
||||
- **Prefer additive migrations** (new tables, new columns) over destructive ones (drops). They merge more cleanly.
|
||||
|
||||
## Squashing
|
||||
|
||||
For long-lived projects, periodically collapse old migrations into a single seed file representing the *current* schema. Move the originals to `archive/` so the audit trail still exists, and create a new `000-baseline-2026.sql` that creates everything at once. Update the migrations runner with a manual `INSERT INTO _migrations` to mark old files as applied.
|
||||
|
||||
## Schema-first models
|
||||
|
||||
When `[GENERATOR] database = true`, models are regenerated from the live schema after each migration. So a typical deploy is:
|
||||
|
||||
```bash
|
||||
./nibiru -mi production
|
||||
# Generator regenerates models on the next request.
|
||||
./nibiru -cache-clear
|
||||
```
|
||||
|
||||
For zero-downtime deploys: turn off the generator (`database = false`) and check in the regenerated models alongside the migrations they depend on.
|
||||
88
docs/src/content/docs/en/cli/overview.md
Normal file
88
docs/src/content/docs/en/cli/overview.md
Normal file
@@ -0,0 +1,88 @@
|
||||
---
|
||||
title: The Nibiru CLI
|
||||
description: Every flag, every subcommand of the ./nibiru binary.
|
||||
---
|
||||
|
||||
The `./nibiru` binary is a compiled command-line tool that ships in every Nibiru project. It scaffolds modules, controllers and plugins, runs migrations, manages the cache, and (with the CMS module) creates and deletes pages.
|
||||
|
||||
```
|
||||
_ _ _ _ _ ______ _
|
||||
| \ | (_) | (_) | ____| | |
|
||||
| \| |_| |__ _ _ __ _ _ | |__ _ __ __ _ _ __ ___ _____ _____ _ _| | __
|
||||
| . ` | | '_ \| | '__| | | | | __| '__/ _` | '_ ` _ \ / _ \ \ /\ / / _ \| '__| |/ /
|
||||
| |\ | | |_) | | | | |_| | | | | | | (_| | | | | | | __/\ V V / (_) | | | <
|
||||
|_| \_|_|_.__/|_|_| \__,_| |_| |_| \__,_|_| |_| |_|\___| \_/\_/ \___/|_| |_|\_\
|
||||
```
|
||||
|
||||
## All flags
|
||||
|
||||
| Flag | What it does |
|
||||
|---|---|
|
||||
| `-m {name}` | Generate a new module named `{name}`. Add `-g` to wire Graylog logging hooks. |
|
||||
| `-c {name}` | Generate a new controller `{name}` plus its template. |
|
||||
| `-p {name} -m {module}` | Generate a new plugin `{name}` inside `{module}`. Add `-g` for Graylog. |
|
||||
| `-cache-clear` | Clear `application/view/templates_c/` and `application/view/cache/`. |
|
||||
| `-s` | Bootstrap framework folders and fix permissions. Run once after install. |
|
||||
| `-mi {env}` | Run migrations from `application/settings/config/database/` against `local`, `staging` or `production`. |
|
||||
| `-mi-reset {env}` | Drop the migrations audit table for `{env}`. **Destructive.** |
|
||||
| `-mi-reset-file {file} {env}` | Forget that a single migration file ran for `{env}`. |
|
||||
| `-ws {URL} -wp {PORT}` | Connect to a WebSocket at `{URL}:{PORT}` (interactive REPL). |
|
||||
| `-new-cms-page {name}` | (CMS module only) Create a new CMS page bound to an existing template. |
|
||||
| `-delete-cms-page {name}` | (CMS module only) Delete a CMS page. |
|
||||
| `-h` | Show the help text. |
|
||||
| `-v` / `-version` | Print the binary's version and the framework version. |
|
||||
|
||||
## Daily commands you'll actually use
|
||||
|
||||
```bash
|
||||
# create a controller + view
|
||||
./nibiru -c products
|
||||
|
||||
# create a module with Graylog hooks
|
||||
./nibiru -m billing -g
|
||||
|
||||
# create a plugin inside that module
|
||||
./nibiru -p invoices -m billing
|
||||
|
||||
# run migrations
|
||||
./nibiru -mi local
|
||||
|
||||
# clear the Smarty cache after a deploy
|
||||
./nibiru -cache-clear
|
||||
|
||||
# show framework version
|
||||
./nibiru -v
|
||||
```
|
||||
|
||||
## Environments
|
||||
|
||||
Most commands honour `APPLICATION_ENV`:
|
||||
|
||||
```bash
|
||||
APPLICATION_ENV=production ./nibiru -mi production
|
||||
APPLICATION_ENV=staging ./nibiru -mi staging
|
||||
```
|
||||
|
||||
The trailing `{env}` argument to `-mi` selects the migrations target; both must match.
|
||||
|
||||
## What the CLI is built on
|
||||
|
||||
The binary is a compiled C++ executable that links against MySQL, PostgreSQL (libpq) and ODBC client libraries. Conditional compilation means a binary built without libpq still works for MySQL-only deployments — graceful degradation rather than a hard dependency.
|
||||
|
||||
You'll find the binary at the project root next to `index.php`. It's executable out of the box (`chmod +x nibiru` if needed).
|
||||
|
||||
## CI integration
|
||||
|
||||
A simple GitHub Actions step:
|
||||
|
||||
```yaml
|
||||
- name: Run migrations
|
||||
run: |
|
||||
APPLICATION_ENV=production ./nibiru -mi production
|
||||
env:
|
||||
DB_HOST: ${{ secrets.DB_HOST }}
|
||||
DB_USER: ${{ secrets.DB_USER }}
|
||||
DB_PASS: ${{ secrets.DB_PASS }}
|
||||
```
|
||||
|
||||
The CLI exits non-zero if any migration fails, so CI will catch SQL errors.
|
||||
131
docs/src/content/docs/en/cli/scaffolding.md
Normal file
131
docs/src/content/docs/en/cli/scaffolding.md
Normal file
@@ -0,0 +1,131 @@
|
||||
---
|
||||
title: Scaffolding Modules & Controllers
|
||||
description: Generate controllers, modules, and plugins with the CLI.
|
||||
---
|
||||
|
||||
## Controllers
|
||||
|
||||
```bash
|
||||
./nibiru -c products
|
||||
```
|
||||
|
||||
Creates two files:
|
||||
|
||||
```
|
||||
application/controller/productsController.php
|
||||
application/view/templates/products.tpl
|
||||
```
|
||||
|
||||
The controller stub:
|
||||
|
||||
```php
|
||||
<?php
|
||||
namespace Nibiru;
|
||||
use Nibiru\Adapter\Controller;
|
||||
|
||||
class productsController extends Controller
|
||||
{
|
||||
public function pageAction() {
|
||||
View::assign(['title' => 'Products']);
|
||||
}
|
||||
|
||||
public function navigationAction() {
|
||||
JsonNavigation::getInstance()->loadJsonNavigationArray();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The template stub:
|
||||
|
||||
```smarty
|
||||
{include 'shared/header.tpl'}
|
||||
<body>
|
||||
{include file="navigation.tpl"}
|
||||
<main class="container">
|
||||
<h1>{$title}</h1>
|
||||
</main>
|
||||
{include 'shared/footer.tpl'}
|
||||
</body>
|
||||
```
|
||||
|
||||
Controllers are PHP files only — no JS or CSS scaffold, so you stay in control of asset wiring.
|
||||
|
||||
## Modules
|
||||
|
||||
```bash
|
||||
./nibiru -m billing
|
||||
```
|
||||
|
||||
Creates:
|
||||
|
||||
```
|
||||
application/module/billing/
|
||||
├── billing.php
|
||||
├── interfaces/billing.php
|
||||
├── plugins/
|
||||
├── settings/billing.ini
|
||||
└── traits/
|
||||
```
|
||||
|
||||
The main class implements `IModule` and exposes a constructor that loads the module's registry config. Add the `-g` flag to include Graylog observer wiring out of the box:
|
||||
|
||||
```bash
|
||||
./nibiru -m billing -g
|
||||
```
|
||||
|
||||
When `-g` is set the scaffold imports a `Graylog` observer, attaches it in the constructor, and emits `notify()` on key state changes — so any GELF-capable Graylog server picks up module events with no extra plumbing.
|
||||
|
||||
## Plugins
|
||||
|
||||
A plugin lives inside a module:
|
||||
|
||||
```bash
|
||||
./nibiru -p invoices -m billing
|
||||
```
|
||||
|
||||
Creates `application/module/billing/plugins/invoices.php`:
|
||||
|
||||
```php
|
||||
<?php
|
||||
namespace Nibiru\Module\Billing\Plugin;
|
||||
use Nibiru\Module\Billing\Billing;
|
||||
|
||||
class Invoices extends Billing
|
||||
{
|
||||
public function listOpen(): array
|
||||
{
|
||||
return \Nibiru\Pdo::fetchAll(
|
||||
'SELECT * FROM invoices WHERE status = :s ORDER BY due_date',
|
||||
[':s' => 'open']
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Plugins inherit from the module so they share its registry, settings, and observer machinery.
|
||||
|
||||
## Bootstrap (`-s`)
|
||||
|
||||
`./nibiru -s` runs after install (or after pulling a fresh checkout) to:
|
||||
|
||||
- Create `application/view/templates_c/` and `application/view/cache/` if missing.
|
||||
- Fix permissions (writable by the web server user) on those folders.
|
||||
- Verify required PHP extensions are loaded.
|
||||
- Verify the database driver in your INI is supported by this binary build.
|
||||
|
||||
It's safe to run repeatedly.
|
||||
|
||||
## Cache clear (`-cache-clear`)
|
||||
|
||||
Wipes both the Smarty compile cache and the rendered HTML cache:
|
||||
|
||||
```bash
|
||||
./nibiru -cache-clear
|
||||
```
|
||||
|
||||
Run after a deploy when:
|
||||
- You changed `.tpl` files,
|
||||
- You changed `[ENGINE] caching` settings,
|
||||
- You modified Smarty plugins.
|
||||
|
||||
The cache regenerates on the next request.
|
||||
100
docs/src/content/docs/en/core/architecture.md
Normal file
100
docs/src/content/docs/en/core/architecture.md
Normal file
@@ -0,0 +1,100 @@
|
||||
---
|
||||
title: Architecture (MMVC)
|
||||
description: How modules, controllers, views, models, the registry and the dispatcher orbit each other.
|
||||
---
|
||||
|
||||
Nibiru is **MMVC**: Model — View — Controller — and a second **M** for **Module**. The first three are familiar; the second M is what gives Nibiru its flavour.
|
||||
|
||||
<figure>
|
||||
<img src="/img/architecture-lotus.png" alt="Cross-section illustration of the Nibiru runtime drawn as a lotus, with each petal labeled as a system component: Router, Controller, View, Model, Module, Registry." />
|
||||
<figcaption>The Nibiru runtime, drawn as a lotus.</figcaption>
|
||||
</figure>
|
||||
|
||||
## The 30-second mental model
|
||||
|
||||
<figure class="lifecycle">
|
||||
<img src="/img/lifecycle.svg" alt="Request lifecycle: Browser to index.php to framework.php to Dispatcher, fanning out to Router, Modules, Autoloader, then to applicationController (navigationAction, custom action, pageAction), then to Smarty render." />
|
||||
<figcaption>Every request follows this path. The dispatcher fans out to Router / Modules / Autoloader, then invokes the controller's actions in order, then hands the assigned data to Smarty.</figcaption>
|
||||
</figure>
|
||||
|
||||
## The five citizens
|
||||
|
||||
### 1. Controllers (`application/controller/`)
|
||||
|
||||
A controller is a class extending `Nibiru\Adapter\Controller`. The dispatcher invokes a fixed sequence on every request:
|
||||
|
||||
1. `navigationAction()` — populate menus / breadcrumb data.
|
||||
2. `<_action>Action()` — only if `?_action=foo` is set.
|
||||
3. `pageAction()` — final render-time data assignment.
|
||||
|
||||
After all three return, `Display::display()` hands the assigned variables to Smarty.
|
||||
|
||||
### 2. Views (`application/view/templates/`)
|
||||
|
||||
Smarty `.tpl` files. The dispatcher resolves `<controller>.tpl` automatically; nested actions go under `templates/<controller>/<action>.tpl`. Every variable passed via `View::assign(['x' => ...])` is available as `{$x}`.
|
||||
|
||||
### 3. Models (`application/model/`)
|
||||
|
||||
Models are **auto-generated** from your DB schema by `Model::__construct(false)` — one PHP class per table. They extend `Nibiru\Adapter\<Driver>\Db` and expose CRUD helpers. You can hand-edit a generated model and disable the regenerator with `[GENERATOR] database = false`.
|
||||
|
||||
### 4. Modules (`application/module/<name>/`)
|
||||
|
||||
A module bundles **its own** traits, plugins, interfaces and settings. The [Registry](/core/registry/) auto-discovers each module's `settings/*.ini` and exposes the parsed config via `Registry::getInstance()->loadModuleConfigByName('users')`. Modules can implement `SplSubject` for the **observer pattern**, letting other parts of the system attach observers and react to state changes.
|
||||
|
||||
### 5. Singletons that hold the cosmos together
|
||||
|
||||
| Singleton | Job |
|
||||
|---|---|
|
||||
| `Config::getInstance()` | Reads `settings.<env>.ini` and merges in module configs. |
|
||||
| `Router::getInstance()` | Parses the URL into controller/action/params; recognises SEO URL forms. |
|
||||
| `Registry::getInstance()` | Module discovery + config caching. |
|
||||
| `Dispatcher::getInstance()` | The conductor. `dispatch::run()` is your application's heartbeat. |
|
||||
| `View::getInstance()` | Wraps Smarty. `View::assign()` is the global template-variable inbox. |
|
||||
|
||||
## Why MMVC, not MVC?
|
||||
|
||||
Plain MVC works until your controllers start sharing logic — auth checks, form factories, third-party API clients. The usual answers are *services + DI container*, but that's a lot of ceremony for a rapid-prototyping framework.
|
||||
|
||||
Nibiru's answer: **modules**. A module *owns* a domain (`users`, `cms`, `analytics`, `tpms-quotes`), exposing its services via plugins controllers can instantiate directly:
|
||||
|
||||
```php
|
||||
// In a controller
|
||||
$user = new \Nibiru\Module\Users\Plugin\User();
|
||||
if (!$user->isAuthorized()) {
|
||||
View::forwardTo('/login');
|
||||
}
|
||||
```
|
||||
|
||||
The module owns its config, its DB tables, its templates, its forms — and is removable as a unit.
|
||||
|
||||
## The observer pattern, in practice
|
||||
|
||||
Some modules implement `SplSubject` so other code can react to events without coupling. From the showcase, the `analytics` module on `prod.maschinen-stockert.de` does exactly this: any controller can `attach()` a tracker (Matomo plugin) and the analytics module `notify()`s on every page-view, without the controller knowing what trackers exist.
|
||||
|
||||
```php
|
||||
class Analytics implements \SplSubject {
|
||||
private \SplObjectStorage $observers;
|
||||
public function __construct() { $this->observers = new \SplObjectStorage(); }
|
||||
public function attach(\SplObserver $o): void { $this->observers->attach($o); }
|
||||
public function detach(\SplObserver $o): void { $this->observers->detach($o); }
|
||||
public function notify(): void {
|
||||
foreach ($this->observers as $o) { $o->update($this); }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## What's deliberately not in the framework
|
||||
|
||||
- **No DI container.** Singletons + plugins do the job.
|
||||
- **No ORM.** Models are generated from the schema; queries use the `Db` adapter or raw SQL via the active driver.
|
||||
- **No template inheritance through Twig/Blade tricks.** Smarty `{include}` is the unit of composition.
|
||||
- **No event bus.** `SplSubject`/`SplObserver` are first-class.
|
||||
- **No first-class background jobs.** The CLI is your scheduler — drive it from cron or systemd timers.
|
||||
|
||||
Less to learn, more to ship.
|
||||
|
||||
## Where to go next
|
||||
|
||||
- [Bootstrap & Dispatcher](/core/dispatcher/) — the lifecycle in code.
|
||||
- [Routing](/core/routing/) — URL → controller mapping rules.
|
||||
- [Modules](/core/modules/) — build your first one.
|
||||
226
docs/src/content/docs/en/core/auth.md
Normal file
226
docs/src/content/docs/en/core/auth.md
Normal file
@@ -0,0 +1,226 @@
|
||||
---
|
||||
title: Auth
|
||||
description: Session-based authentication, the prebuilt login form, and the User plugin pattern from production.
|
||||
---
|
||||
|
||||
Nibiru ships a session-based authentication core (`Nibiru\Auth`) and a `users` module that gives you a working login form, an authorisation check, and the database schema in three commands.
|
||||
|
||||
## The Auth core
|
||||
|
||||
`Auth::auth($login, $password)` is the lowest-level call. It:
|
||||
|
||||
1. Looks up the user by `user_login`.
|
||||
2. Decrypts the stored password using the salt from `[SECURITY] password_hash`.
|
||||
3. On match, replaces `$_SESSION` with `['auth' => ['session_id' => …, 'user_id' => …, 'login' => …]]`.
|
||||
|
||||
```php
|
||||
$auth = new \Nibiru\Auth();
|
||||
if ($auth->auth($_POST['login'], $_POST['password'])) {
|
||||
View::forwardTo('/dashboard');
|
||||
} else {
|
||||
View::assign(['error' => 'Invalid credentials.']);
|
||||
}
|
||||
```
|
||||
|
||||
:::caution
|
||||
The default schema stores passwords with **AES_DECRYPT** keyed by the INI salt — reversible. Adequate for prototyping, **not** for public production. Replace it with `password_hash()` / `password_verify()` for real apps. See *Hardening*, below.
|
||||
:::
|
||||
|
||||
## The Users module
|
||||
|
||||
Generate it once with the CLI:
|
||||
|
||||
```bash
|
||||
./nibiru -m users
|
||||
```
|
||||
|
||||
Then run the matching migrations:
|
||||
|
||||
```bash
|
||||
./nibiru -mi local
|
||||
```
|
||||
|
||||
You now have:
|
||||
|
||||
- `application/module/users/` — the module folder.
|
||||
- `users` table — created by `005-user.sql`.
|
||||
- `User` plugin — `Nibiru\Module\Users\Plugin\User` with `isAuthorized()`, `loginForm()`, `currentUser()`.
|
||||
|
||||
## A complete login flow
|
||||
|
||||
`application/controller/loginController.php`:
|
||||
|
||||
```php
|
||||
<?php
|
||||
namespace Nibiru;
|
||||
use Nibiru\Adapter\Controller;
|
||||
use Nibiru\Module\Users\Plugin\User;
|
||||
|
||||
class loginController extends Controller
|
||||
{
|
||||
private User $user;
|
||||
|
||||
public function __construct() {
|
||||
parent::__construct();
|
||||
$this->user = new User();
|
||||
}
|
||||
|
||||
public function pageAction() {
|
||||
if ($this->user->isAuthorized()) {
|
||||
View::forwardTo('/');
|
||||
return;
|
||||
}
|
||||
View::assign([
|
||||
'title' => 'Sign in',
|
||||
'loginForm' => $this->user->loginForm(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function submitAction() {
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') return;
|
||||
$auth = new Auth();
|
||||
if ($auth->auth($_POST['login'] ?? '', $_POST['password'] ?? '')) {
|
||||
View::forwardTo('/');
|
||||
} else {
|
||||
View::assign(['error' => 'Invalid login.']);
|
||||
}
|
||||
}
|
||||
|
||||
public function logoutAction() {
|
||||
unset($_SESSION['auth']);
|
||||
session_regenerate_id(true);
|
||||
View::forwardTo('/login');
|
||||
}
|
||||
|
||||
public function navigationAction() {
|
||||
JsonNavigation::getInstance()->loadJsonNavigationArray();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`application/view/templates/login.tpl`:
|
||||
|
||||
```smarty
|
||||
{include 'shared/header.tpl'}
|
||||
<body>
|
||||
{include file="navigation.tpl"}
|
||||
<main class="container">
|
||||
<h1>{$title}</h1>
|
||||
{if $error}<div class="alert alert-danger">{$error}</div>{/if}
|
||||
{$loginForm nofilter}
|
||||
</main>
|
||||
{include 'shared/footer.tpl'}
|
||||
</body>
|
||||
```
|
||||
|
||||
`Form::action="/login/submit"` is set inside `loginForm()` so the form posts to the right action.
|
||||
|
||||
## Guarding controllers
|
||||
|
||||
The simple check:
|
||||
|
||||
```php
|
||||
public function pageAction() {
|
||||
if (!$this->user->isAuthorized()) {
|
||||
View::forwardTo('/login');
|
||||
return;
|
||||
}
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
For role-aware checks, the showcase apps use the `Acl` plugin from the same module:
|
||||
|
||||
```php
|
||||
use Nibiru\Module\Users\Plugin\Acl;
|
||||
|
||||
if (!Acl::can('edit', 'documents')) {
|
||||
View::forwardTo('/forbidden');
|
||||
return;
|
||||
}
|
||||
```
|
||||
|
||||
Tables `acl`, `user_to_acl`, `acl-data` (migrations 001, 008, 011) form the role/permission base.
|
||||
|
||||
## Hardening
|
||||
|
||||
For production, replace `Auth::auth()` with a hardened version:
|
||||
|
||||
```php
|
||||
namespace Nibiru;
|
||||
use Nibiru\Pdo;
|
||||
|
||||
class HardenedAuth
|
||||
{
|
||||
public function auth(string $login, string $password): bool {
|
||||
$row = Pdo::fetchRow(
|
||||
'SELECT user_id, user_pass FROM "user" WHERE user_login = :l AND user_account_active = 1',
|
||||
[':l' => $login]
|
||||
);
|
||||
if (!$row || !password_verify($password, $row['user_pass'])) {
|
||||
return false;
|
||||
}
|
||||
if (password_needs_rehash($row['user_pass'], PASSWORD_ARGON2ID)) {
|
||||
Pdo::update('user', [
|
||||
'user_pass' => password_hash($password, PASSWORD_ARGON2ID),
|
||||
], ['user_id' => $row['user_id']]);
|
||||
}
|
||||
session_regenerate_id(true);
|
||||
$_SESSION['auth'] = [
|
||||
'session_id' => session_id(),
|
||||
'user_id' => $row['user_id'],
|
||||
'login' => $login,
|
||||
];
|
||||
return true;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Migrate existing rows on first login with `password_needs_rehash`.
|
||||
|
||||
## CSRF
|
||||
|
||||
Nibiru doesn't generate CSRF tokens for you. Add them yourself:
|
||||
|
||||
```php
|
||||
public function pageAction() {
|
||||
if (!isset($_SESSION['csrf'])) {
|
||||
$_SESSION['csrf'] = bin2hex(random_bytes(16));
|
||||
}
|
||||
View::assign(['csrf' => $_SESSION['csrf']]);
|
||||
}
|
||||
|
||||
public function submitAction() {
|
||||
if (!hash_equals($_SESSION['csrf'] ?? '', $_POST['csrf'] ?? '')) {
|
||||
http_response_code(419);
|
||||
return;
|
||||
}
|
||||
// ...handle submission...
|
||||
}
|
||||
```
|
||||
|
||||
Embed `<input type="hidden" name="csrf" value="{$csrf}">` in your form.
|
||||
|
||||
## QR-code login (TPMS pattern)
|
||||
|
||||
Production apps include a QR-code-based magic-link login that issues short-lived tokens. The flow:
|
||||
|
||||
1. User scans QR → URL is `/login/token/<one-time-token>`.
|
||||
2. `tokenAction` validates and creates a session.
|
||||
|
||||
The framework already depends on `bacon/bacon-qr-code` and `picqer/php-barcode-generator` via Composer, so you can render QR codes inline:
|
||||
|
||||
```php
|
||||
$writer = new \BaconQrCode\Writer(new \BaconQrCode\Renderer\ImageRenderer(
|
||||
new \BaconQrCode\Renderer\RendererStyle\RendererStyle(220),
|
||||
new \BaconQrCode\Renderer\Image\SvgImageBackEnd()
|
||||
));
|
||||
$svg = $writer->writeString('https://app.example.com/login/token/' . $token);
|
||||
View::assign(['qr' => $svg]);
|
||||
```
|
||||
|
||||
## Common pitfalls
|
||||
|
||||
- **`$_SESSION` is replaced, not merged** by the default `Auth::auth()`. Anything you stored before login is lost. Save and restore explicitly if needed.
|
||||
- **No rate limiting**. Add Fail2Ban or a middleware-style observer.
|
||||
- **Session cookies need flags.** Set `session.cookie_secure = 1`, `session.cookie_httponly = 1`, and `session.cookie_samesite = "Lax"` in php.ini for production.
|
||||
149
docs/src/content/docs/en/core/config.md
Normal file
149
docs/src/content/docs/en/core/config.md
Normal file
@@ -0,0 +1,149 @@
|
||||
---
|
||||
title: Config & Settings
|
||||
description: How Nibiru loads configuration from environment-specific INI files.
|
||||
---
|
||||
|
||||
Nibiru's config layer is intentionally simple: parse a single `settings.<env>.ini` file at boot, expose it through a singleton, and let modules add their own INIs through the [Registry](/core/registry/).
|
||||
|
||||
## Environment selection
|
||||
|
||||
The `APPLICATION_ENV` environment variable picks which file to load:
|
||||
|
||||
| `APPLICATION_ENV` | File loaded |
|
||||
|---|---|
|
||||
| `development` (default) | `application/settings/config/settings.development.ini` |
|
||||
| `staging` | `application/settings/config/settings.staging.ini` |
|
||||
| `production` | `application/settings/config/settings.production.ini` |
|
||||
|
||||
```bash
|
||||
export APPLICATION_ENV=production
|
||||
./nibiru -mi production
|
||||
```
|
||||
|
||||
If `APPLICATION_ENV` is unset, Nibiru defaults to `development`.
|
||||
|
||||
## Sections
|
||||
|
||||
A typical `settings.<env>.ini`:
|
||||
|
||||
```ini
|
||||
[ENGINE]
|
||||
templates = "/../../application/view/templates/"
|
||||
templates_c = "/../../application/view/templates_c/"
|
||||
cache = "/../../application/view/cache/"
|
||||
config_dir = "/../../application/view/configs/"
|
||||
caching = false
|
||||
debug = true
|
||||
error.controller = "error"
|
||||
|
||||
[AUTOLOADER]
|
||||
iface.pos[] = "users"
|
||||
iface.pos[] = "cms"
|
||||
class.pos[] = "users"
|
||||
class.pos[] = "cms"
|
||||
|
||||
[SETTINGS]
|
||||
page.url = "https://my-app.local"
|
||||
navigation = "/../../application/settings/config/navigation/main.json"
|
||||
modules.path = "/../../application/module/"
|
||||
entries.per.page = 25
|
||||
smarty.css[] = "/public/css/app.css"
|
||||
smarty.js[] = "/public/js/app.js"
|
||||
timezone = "Europe/Vienna"
|
||||
|
||||
[DATABASE]
|
||||
driver = "pdo"
|
||||
hostname = "localhost"
|
||||
port = 3306
|
||||
username = "nibiru"
|
||||
password = "secret"
|
||||
basename = "nibiru_dev"
|
||||
encoding = "utf8mb4"
|
||||
is.active = true
|
||||
|
||||
[SECURITY]
|
||||
password_hash = "change-me-at-once"
|
||||
|
||||
[GENERATOR]
|
||||
database = true
|
||||
database.overwrite = true
|
||||
|
||||
[EMAIL]
|
||||
host = "smtp.example.com"
|
||||
port = 587
|
||||
encryption = "tls"
|
||||
username = "noreply@example.com"
|
||||
password = "smtp-secret"
|
||||
from = "Nibiru <noreply@example.com>"
|
||||
|
||||
[NIBIRU_ROUTING]
|
||||
; optional regex routes — see /core/routing/
|
||||
|
||||
[NIBIRU_SECURITY]
|
||||
password_hash = "another-salt-for-AES_DECRYPT"
|
||||
```
|
||||
|
||||
## Reading config
|
||||
|
||||
```php
|
||||
$cfg = \Nibiru\Config::getInstance()->getConfig();
|
||||
$cfg['DATABASE']['driver']; // 'pdo'
|
||||
$cfg['SETTINGS']['page.url']; // 'https://my-app.local'
|
||||
```
|
||||
|
||||
For deeply-nested configs use the typed constants from the View interface:
|
||||
|
||||
```php
|
||||
$cfg[\Nibiru\View::NIBIRU_SETTINGS]['smarty.css']; // ['/public/css/app.css']
|
||||
```
|
||||
|
||||
## Module configs
|
||||
|
||||
Each module under `application/module/<name>/settings/` can carry its own INI files. The Registry picks them up automatically and exposes them through:
|
||||
|
||||
```php
|
||||
$users = \Nibiru\Registry::getInstance()->loadModuleConfigByName('users');
|
||||
$users->session_lifetime; // from [USERS] section in users.ini
|
||||
```
|
||||
|
||||
The Registry prefers `<module>.<env>.ini` over `<module>.ini`, so you get per-environment overrides for free.
|
||||
|
||||
## Secrets
|
||||
|
||||
INI files are *plain text*. Two production-safe options:
|
||||
|
||||
### A. Environment overlay
|
||||
|
||||
Keep `settings.production.ini` in version control with placeholders, and inject real values from environment variables at runtime:
|
||||
|
||||
```ini
|
||||
[DATABASE]
|
||||
password = "${DB_PASSWORD}"
|
||||
```
|
||||
|
||||
Then expand them in your container/CI:
|
||||
|
||||
```bash
|
||||
envsubst < settings.production.ini.tpl > settings.production.ini
|
||||
```
|
||||
|
||||
### B. Out-of-tree config
|
||||
|
||||
Keep `settings.production.ini` outside the repo and symlink at deploy time:
|
||||
|
||||
```bash
|
||||
ln -s /etc/nibiru/settings.production.ini \
|
||||
application/settings/config/settings.production.ini
|
||||
```
|
||||
|
||||
The framework doesn't care where the file lives as long as `parse_ini_file` can read it.
|
||||
|
||||
## Reload semantics
|
||||
|
||||
Config is read **once** at boot, into the `Settings` static. Changes to the INI require a request cycle to take effect (or for long-running scripts, an explicit `Settings::setConfig(\Nibiru\Config::getEnv())` call).
|
||||
|
||||
## Common pitfalls
|
||||
|
||||
- **Path leading `/../../`.** The framework's INI paths are *relative to the framework directory*. They must start with `/../../` to escape into your project root. Yes, this looks weird; yes, it works.
|
||||
- **Boolean parsing.** `parse_ini_file` is permissive — `true`, `on`, `1` all become `1`; `false`, `off`, `0`, `""` become `0`. Triple-quote strings if you really need a literal `"true"`.
|
||||
- **Array values.** Use `[]` syntax (`smarty.css[] = …`) — Nibiru relies on these being arrays.
|
||||
167
docs/src/content/docs/en/core/controllers.md
Normal file
167
docs/src/content/docs/en/core/controllers.md
Normal file
@@ -0,0 +1,167 @@
|
||||
---
|
||||
title: Controllers
|
||||
description: Writing Nibiru controllers — the action lifecycle, View::assign, and patterns from production code.
|
||||
---
|
||||
|
||||
A Nibiru controller is a class that extends `Nibiru\Adapter\Controller`, lives in `application/controller/<name>Controller.php`, and is loaded automatically by the [Dispatcher](/core/dispatcher/) when a URL like `/<name>/...` arrives.
|
||||
|
||||
## Anatomy of a controller
|
||||
|
||||
```php
|
||||
<?php
|
||||
namespace Nibiru;
|
||||
use Nibiru\Adapter\Controller;
|
||||
|
||||
class productsController extends Controller
|
||||
{
|
||||
public function pageAction() {
|
||||
View::assign([
|
||||
'title' => 'Products',
|
||||
'products' => $this->loadProducts(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function navigationAction() {
|
||||
JsonNavigation::getInstance()->loadJsonNavigationArray();
|
||||
}
|
||||
|
||||
public function detailAction() {
|
||||
$id = (int) ($_REQUEST['id'] ?? 0);
|
||||
View::assign(['product' => $this->loadProduct($id)]);
|
||||
}
|
||||
|
||||
private function loadProducts(): array { /* ... */ return []; }
|
||||
private function loadProduct(int $id): array { /* ... */ return []; }
|
||||
}
|
||||
```
|
||||
|
||||
## The action lifecycle
|
||||
|
||||
When the dispatcher invokes a controller, it calls methods in this fixed order:
|
||||
|
||||
1. **`navigationAction()`** — populate global menus, breadcrumbs, role-aware nav.
|
||||
2. **`<verb>Action()`** — only if `?_action=<verb>` is set or the URL has a second segment that names an action.
|
||||
3. **`pageAction()`** — last call before render.
|
||||
|
||||
Both `navigationAction()` and `pageAction()` are **always called**, even for unknown actions. This is convenient (you never need to check) but can surprise you if you assume actions are exclusive.
|
||||
|
||||
## Talking to the view
|
||||
|
||||
`View::assign(['key' => $value, ...])` is how data reaches templates. It's static and can be called as many times as you want — later calls overwrite earlier ones.
|
||||
|
||||
```php
|
||||
View::assign(['title' => 'Products']);
|
||||
View::assign(['products' => $list]);
|
||||
|
||||
// In templates/products.tpl:
|
||||
// {$title} → "Products"
|
||||
// {$products} → the array
|
||||
```
|
||||
|
||||
Convenience helpers from the base controller:
|
||||
|
||||
```php
|
||||
$this->getRequest('id', false); // $_REQUEST['id'] ?? false
|
||||
$this->getPost('email', ''); // $_POST['email'] ?? ''
|
||||
$this->getGet('page', 1); // $_GET['page'] ?? 1
|
||||
$this->getServer('REQUEST_URI'); // $_SERVER['REQUEST_URI']
|
||||
$this->getFiles('upload'); // $_FILES['upload']
|
||||
$this->getSession('auth'); // $_SESSION['auth']
|
||||
```
|
||||
|
||||
These exist because `Controller` is `final`-friendly: you can mock them in tests by substituting a child class.
|
||||
|
||||
## Forwarding
|
||||
|
||||
To redirect inside an action:
|
||||
|
||||
```php
|
||||
View::forwardTo('/login'); // 302 to the URL, exits
|
||||
View::forwardToJsonHeader(); // sets Content-Type: application/json
|
||||
```
|
||||
|
||||
`forwardToJsonHeader()` is the canonical pattern for JSON endpoints — set the header, assign `data`, return. The view layer does the rest.
|
||||
|
||||
## Multiple actions per controller
|
||||
|
||||
Nibiru is happy to host any number of actions per controller. The TPMS `erpController` from production has `pageAction`, `navigationAction`, plus `syncAction`, `statusAction`, `dryRunAction`, `cancelAction`, etc. — each invoked via `?_action=sync` or `/erp/sync`.
|
||||
|
||||
```php
|
||||
// /erp/sync → $_REQUEST['_action'] = 'sync'
|
||||
public function syncAction(): void
|
||||
{
|
||||
View::forwardToJsonHeader();
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
View::assign(['data' => ['success' => false, 'error' => 'POST method required']]);
|
||||
return;
|
||||
}
|
||||
$result = AlphaplanSyncService::getInstance()->syncAbDocuments();
|
||||
View::assign(['data' => $result]);
|
||||
}
|
||||
```
|
||||
|
||||
## Working with modules
|
||||
|
||||
Controllers are thin. Most logic should live in modules and their plugins:
|
||||
|
||||
```php
|
||||
namespace Nibiru;
|
||||
use Nibiru\Adapter\Controller;
|
||||
use Nibiru\Module\Users\Plugin\User;
|
||||
|
||||
class loginController extends Controller
|
||||
{
|
||||
private User $user;
|
||||
|
||||
public function __construct() {
|
||||
parent::__construct();
|
||||
$this->user = new User();
|
||||
}
|
||||
|
||||
public function authAction() {
|
||||
if (!$this->user->isAuthorized()) {
|
||||
View::assign(['loginForm' => $this->user->loginForm()]);
|
||||
} else {
|
||||
View::forwardTo('/index');
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This *delegation* pattern is consistent across the showcase apps: controllers orchestrate, modules do the work.
|
||||
|
||||
## Multi-language / CMS-driven content
|
||||
|
||||
A pattern from `prod.maschinen-stockert.de` — load all on-page text from a CMS table keyed by controller path:
|
||||
|
||||
```php
|
||||
public function pageAction() {
|
||||
$controllerPath = $this->getController()
|
||||
. '/' . $this->getRequest('_action', 'page');
|
||||
|
||||
$texts = Cms::init($this->getController())
|
||||
->loadCmsTemplateTextsByControllerPath($controllerPath, $this->language);
|
||||
|
||||
foreach ($texts as $t) {
|
||||
View::assign([
|
||||
$t['cms_template_texts_text_identifier'] =>
|
||||
$t['cms_template_texts_text_content']
|
||||
]);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Result: non-developers can change copy without touching code. The CMS module owns the table and the editor UI; the controller just loads strings.
|
||||
|
||||
## Generating controllers
|
||||
|
||||
The CLI scaffolds a controller and its template in one shot:
|
||||
|
||||
```bash
|
||||
./nibiru -c products
|
||||
```
|
||||
|
||||
→ `application/controller/productsController.php`
|
||||
→ `application/view/templates/products.tpl`
|
||||
|
||||
Both are populated with the canonical skeleton. You're ready to write.
|
||||
169
docs/src/content/docs/en/core/database.md
Normal file
169
docs/src/content/docs/en/core/database.md
Normal file
@@ -0,0 +1,169 @@
|
||||
---
|
||||
title: Database & Migrations
|
||||
description: Multi-driver Db adapter, schema-driven model generation, and numbered SQL migrations.
|
||||
---
|
||||
|
||||
Nibiru ships with **five database drivers** behind a unified `Db` adapter, plus a numbered-SQL migration runner driven by the [CLI](/cli/migrations/).
|
||||
|
||||
## Driver choice
|
||||
|
||||
Set the active driver in `[DATABASE] driver` of your INI:
|
||||
|
||||
| Driver | Backend | Notes |
|
||||
|---|---|---|
|
||||
| `mysql` | Native MySQL/MariaDB (`mysqli`) | Fastest, no PDO overhead. |
|
||||
| `pdo` | PDO MySQL | Recommended default. Prepared statements, drivers are widely used. |
|
||||
| `postgres` | PostgreSQL via **ODBC** | When your server only has ODBC available. |
|
||||
| `psql` | PostgreSQL via libpq (`pg_*`) | Native PostgreSQL. |
|
||||
| `postgresql` | A wrapper that picks the best PostgreSQL transport at runtime. | |
|
||||
|
||||
```ini
|
||||
[DATABASE]
|
||||
driver = "pdo"
|
||||
hostname = "localhost"
|
||||
port = 3306
|
||||
username = "nibiru"
|
||||
password = "secret"
|
||||
basename = "nibiru_dev"
|
||||
encoding = "utf8mb4"
|
||||
is.active = true
|
||||
```
|
||||
|
||||
The dispatcher initialises the driver during boot. Switching drivers requires only a config change and a restart.
|
||||
|
||||
## The Db adapter
|
||||
|
||||
Every generated model extends a driver-specific `Db` adapter (`Adapter\MySQL\Db`, `Adapter\PostgreSQL\Db`, `Adapter\Odbc\Db`). All adapters share the same surface area:
|
||||
|
||||
```php
|
||||
use Nibiru\Pdo;
|
||||
|
||||
// Read a single row by primary-key columns
|
||||
$row = Pdo::fetchRowInArrayById('users', ['user_id' => 42]);
|
||||
|
||||
// Run a parameterised query
|
||||
$rows = Pdo::fetchAll(
|
||||
'SELECT * FROM products WHERE category = :cat',
|
||||
[':cat' => 'gold-plating']
|
||||
);
|
||||
|
||||
// Insert
|
||||
Pdo::insert('products', [
|
||||
'name' => 'Marduk Gold Plating',
|
||||
'price' => 99.0,
|
||||
]);
|
||||
|
||||
// Update
|
||||
Pdo::update('products',
|
||||
['price' => 89.0],
|
||||
['id' => 1]);
|
||||
|
||||
// Delete
|
||||
Pdo::delete('products', ['id' => 1]);
|
||||
|
||||
// Last insert id
|
||||
Pdo::lastInsertId();
|
||||
```
|
||||
|
||||
Driver-specific helpers exist on the adapter classes themselves (`\Nibiru\Mysql::query()`, `\Nibiru\Postgresql::pgQuery()`, …) when you need raw access.
|
||||
|
||||
## Schema-first models
|
||||
|
||||
When `[GENERATOR] database = true`, the dispatcher regenerates one PHP class per table on every request. See [Models](/core/models/).
|
||||
|
||||
In production:
|
||||
|
||||
```ini
|
||||
[GENERATOR]
|
||||
database = false
|
||||
database.overwrite = false
|
||||
```
|
||||
|
||||
Re-generate only when you migrate the schema.
|
||||
|
||||
## Migrations
|
||||
|
||||
Migrations are **plain SQL files** in `application/settings/config/database/`, numbered for execution order:
|
||||
|
||||
```
|
||||
application/settings/config/database/
|
||||
├── 001-acl.sql
|
||||
├── 002-account.sql
|
||||
├── 003-api_registry.sql
|
||||
├── 004-timeanddate.sql
|
||||
├── 005-user.sql
|
||||
├── 006-user_to_account.sql
|
||||
├── 011-acl-data.sql
|
||||
├── 012-add-unique-key-user.sql
|
||||
└── 013-add-account-email.sql
|
||||
```
|
||||
|
||||
The CLI applies them with:
|
||||
|
||||
```bash
|
||||
./nibiru -mi local
|
||||
APPLICATION_ENV=staging ./nibiru -mi staging
|
||||
APPLICATION_ENV=production ./nibiru -mi production
|
||||
```
|
||||
|
||||
The runner creates a `_migrations` table (per-driver name) and skips files already applied. Each file runs as a single batch; if a statement errors mid-way through, fix the SQL and re-run.
|
||||
|
||||
### File naming conventions
|
||||
|
||||
- Three-digit zero-padded numeric prefix: `NNN-<slug>.sql`.
|
||||
- Slugs describe the change (`-add-account-email`, `-drop-wrong-constraints`).
|
||||
- One logical change per file. Don't squash.
|
||||
- For data-only seeds, use `-data.sql` suffix (`011-acl-data.sql`).
|
||||
|
||||
### Idempotency
|
||||
|
||||
Use `IF NOT EXISTS` and `IF EXISTS` so files can re-run safely:
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS api_registry (
|
||||
api_registry_id INT(11) NOT NULL AUTO_INCREMENT,
|
||||
api_registry_name VARCHAR(255) NOT NULL,
|
||||
api_registry_token VARCHAR(64) NOT NULL,
|
||||
PRIMARY KEY (api_registry_id),
|
||||
UNIQUE KEY api_registry_token_uk (api_registry_token)
|
||||
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4;
|
||||
```
|
||||
|
||||
```sql
|
||||
ALTER TABLE user
|
||||
ADD UNIQUE KEY user_login_uk (user_login)
|
||||
/* skip if exists; on MySQL 8 you can wrap with IF NOT EXISTS */;
|
||||
```
|
||||
|
||||
### Reset commands (use with care)
|
||||
|
||||
```bash
|
||||
./nibiru -mi-reset local # forget all applied migrations
|
||||
./nibiru -mi-reset-file 005-user.sql local # forget a single file's run record
|
||||
```
|
||||
|
||||
These don't drop tables — only the `_migrations` audit table. Combine with manual `DROP` if you really want a clean slate.
|
||||
|
||||
## PostgreSQL specifics
|
||||
|
||||
When `driver = "psql"` or `"postgresql"`:
|
||||
|
||||
- Use `SERIAL` / `BIGSERIAL` instead of `AUTO_INCREMENT`.
|
||||
- Tables in `information_schema.tables` rather than `SHOW TABLES`.
|
||||
- Quoting: identifiers are double-quoted (`"user"` is the correct form because `user` is reserved).
|
||||
- The conditional-compile flag in the CLI means PostgreSQL builds gracefully degrade if libpq isn't present at compile time — check `./nibiru -v` to confirm support.
|
||||
|
||||
## Connection pooling
|
||||
|
||||
There's no built-in pool. For high-traffic apps use:
|
||||
|
||||
- **MariaDB / MySQL:** ProxySQL or HAProxy in front of the DB.
|
||||
- **PostgreSQL:** PgBouncer in transaction-pooling mode.
|
||||
|
||||
Nibiru opens one connection per request, so a pooler is usually all you need.
|
||||
|
||||
## Common pitfalls
|
||||
|
||||
- **`is.active = false`** silently disables connections — check this when queries return `null`.
|
||||
- **Generator on in production.** With `[GENERATOR] database = true`, every request rewrites your model files. Turn it off after deploys.
|
||||
- **Mixed drivers.** Picking `mysql` when running PostgreSQL gets you a successful `Pdo::fetchAll` that returns `[]` — the connection silently fails. Always confirm with `SELECT 1` in a smoke test.
|
||||
116
docs/src/content/docs/en/core/dispatcher.md
Normal file
116
docs/src/content/docs/en/core/dispatcher.md
Normal file
@@ -0,0 +1,116 @@
|
||||
---
|
||||
title: Bootstrap & Dispatcher
|
||||
description: How a Nibiru request flows from index.php through the dispatcher to your controller.
|
||||
---
|
||||
|
||||
A Nibiru request takes one trip through three files: `index.php`, `core/framework.php`, and `core/c/dispatcher.php`. Reading them in that order tells you everything.
|
||||
|
||||
## index.php
|
||||
|
||||
```php
|
||||
<?php
|
||||
require_once 'core/framework.php';
|
||||
```
|
||||
|
||||
That's the whole file. `index.php` exists only to give the web server a target.
|
||||
|
||||
## core/framework.php
|
||||
|
||||
`framework.php` requires the framework's classes in dependency order — settings, registry, router, engine, autoloader, all DB drivers, all 28 form types, view, controller, modules, auth, debug, display — and finishes with:
|
||||
|
||||
```php
|
||||
Nibiru\Dispatcher::getInstance()->run();
|
||||
```
|
||||
|
||||
That single call is your application's heartbeat.
|
||||
|
||||
## Dispatcher::run()
|
||||
|
||||
The simplified flow:
|
||||
|
||||
```php
|
||||
public function run() {
|
||||
date_default_timezone_set(Config::getInstance()->getConfig()
|
||||
[View::NIBIRU_SETTINGS]['timezone']);
|
||||
|
||||
if (Config::getInstance()->getConfig()
|
||||
[self::CONFIG_GENERATOR_SECTION][self::GENERATOR_DATABASE]) {
|
||||
new Model(false); // 1. (re)generate models from schema
|
||||
}
|
||||
|
||||
Router::getInstance()->route(); // 2. parse the URL
|
||||
Auto::loader()->loadModelFiles(); // 3. load model files
|
||||
Auto::loader()->loadModules(); // 4. load module classes
|
||||
|
||||
$tpl = Router::getInstance()->tplName();
|
||||
$controllerFile = __DIR__ . "/../../application/controller/{$tpl}Controller.php";
|
||||
|
||||
if (is_file($controllerFile)) { // 5. controller file exists
|
||||
require_once $controllerFile;
|
||||
$class = "Nibiru\\{$tpl}Controller";
|
||||
$controller = new $class();
|
||||
|
||||
if (array_key_exists('_action', $_REQUEST)) {
|
||||
$action = $_REQUEST['_action'] . 'Action';
|
||||
$controller->navigationAction();
|
||||
if (method_exists($controller, $action)) {
|
||||
$controller->$action(); // 6. optional named action
|
||||
}
|
||||
$controller->pageAction();
|
||||
} else {
|
||||
$controller->navigationAction();
|
||||
$controller->pageAction();
|
||||
}
|
||||
|
||||
Display::getInstance()->display(); // 7. render Smarty
|
||||
} else {
|
||||
// 8. soft 404 — render the configured error controller
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## The action sequence
|
||||
|
||||
Every request goes through the same three steps if `?_action=foo` is set:
|
||||
|
||||
1. `navigationAction()` — populate menus, breadcrumbs.
|
||||
2. `fooAction()` — run the named action.
|
||||
3. `pageAction()` — last chance to assign template data.
|
||||
|
||||
Without `_action`, only steps 1 and 3 run.
|
||||
|
||||
This means **stateless render-time logic belongs in `pageAction()`**, and **navigation data belongs in `navigationAction()`**, even if it feels like duplicating effort. Two controllers in the same project will both have a `navigationAction()`; that's correct.
|
||||
|
||||
## Soft 404
|
||||
|
||||
If the matched controller file doesn't exist, Nibiru renders a *soft 404* — it returns a 200 OK and renders the controller named in `[ENGINE] error.controller` (default: `error`). This is intentional: it lets you serve nice error pages without server-level configuration.
|
||||
|
||||
If you want a real `404 Not Found` HTTP code, set it in your error controller:
|
||||
|
||||
```php
|
||||
public function pageAction() {
|
||||
http_response_code(404);
|
||||
View::assign(['title' => 'Lost in the void']);
|
||||
}
|
||||
```
|
||||
|
||||
## Auto::loader()
|
||||
|
||||
Two auto-loaders run before the controller is constructed:
|
||||
|
||||
- **`loadModelFiles()`** scans `application/model/` and includes every `.php` file there. Generated models are flat files, not namespaced packages, so this is a simple `require_once` loop.
|
||||
- **`loadModules()`** walks `application/module/<name>/` and loads each module's main class plus its trait, plugin and interface files. The Registry indexes each module's settings INI at the same time.
|
||||
|
||||
Both use the configured paths in `[SETTINGS] modules.path` etc., so you can override them per environment.
|
||||
|
||||
## SEO URL handling
|
||||
|
||||
Before the controller resolution, `Router::route()` runs `handleSeoUrls()` which detects URLs of the form `/controller/<slug>/<id>` (where the second segment is *not* a known action and the third is numeric). Those get rewritten internally to `/controller/detail/` with `$_REQUEST['id']` and `$_REQUEST['slug']` populated. Read more in [Routing](/core/routing/).
|
||||
|
||||
## When to override the dispatcher
|
||||
|
||||
Almost never. The cleaner extension points are:
|
||||
|
||||
- **Custom error controller** — set `[ENGINE] error.controller`.
|
||||
- **Pre-controller hooks** — load a module that registers an observer, then attach it inside `navigationAction()`.
|
||||
- **Cron-style entry** — run the framework headless via the `nibiru` CLI rather than `index.php`.
|
||||
253
docs/src/content/docs/en/core/forms.md
Normal file
253
docs/src/content/docs/en/core/forms.md
Normal file
@@ -0,0 +1,253 @@
|
||||
---
|
||||
title: Forms
|
||||
description: Build forms fluently with Nibiru's static factory.
|
||||
---
|
||||
|
||||
Forms in Nibiru are built **fluently** by calling static methods on `\Nibiru\Factory\Form`. Each call appends an HTML fragment to an internal static buffer; a final `Form::addForm()` wraps the buffer in a `<form>` element and returns the rendered HTML string.
|
||||
|
||||
## Import the factory
|
||||
|
||||
```php
|
||||
use Nibiru\Factory\Form;
|
||||
```
|
||||
|
||||
That's the only `use` you need. Every input type is a static method on this class.
|
||||
|
||||
## The full method catalogue
|
||||
|
||||
There are three flavours of method, by historical naming.
|
||||
|
||||
### `addInputType*` — for actual `<input>` elements
|
||||
|
||||
```
|
||||
addInputTypeText addInputTypePassword addInputTypeEmail
|
||||
addInputTypeDate addInputTypeDatetime addInputTypeColor
|
||||
addInputTypeRadio addInputTypeCheckbox addInputTypeSwitch
|
||||
addInputTypeSubmit addInputTypeTextarea
|
||||
```
|
||||
|
||||
### `addType*` — for non-`<input>` form elements
|
||||
|
||||
```
|
||||
addTypeFileUpload addTypeHidden addTypeImageSubmit
|
||||
addTypeNumber addTypeRange addTypeReset
|
||||
addTypeSearch addTypeTelefon addTypeUrl
|
||||
addTypeButton addTypeLabel
|
||||
```
|
||||
|
||||
### `addSelect` / `addSelectOption` — for `<select>` + `<option>`
|
||||
|
||||
```
|
||||
addSelect addSelectOption
|
||||
```
|
||||
|
||||
### Layout helpers
|
||||
|
||||
```
|
||||
addOpenDiv addCloseDiv addOpenAny addCloseAny
|
||||
addOpenSpan addCloseSpan
|
||||
```
|
||||
|
||||
### Lifecycle
|
||||
|
||||
```
|
||||
create // reset the static buffer; call before building a new form
|
||||
addForm // wrap the buffer in <form>...</form> and return as a string
|
||||
```
|
||||
|
||||
:::caution[The naming inconsistency is intentional, sort of]
|
||||
`addInputTypePassword` vs `addTypePassword`, `addInputTypeFileupload` vs `addTypeFileUpload` — the prefix matches whether the element is an `<input type="…">` (Input prefix) or some other tag (Type prefix). It's awkward but stable; the CLI's `./nibiru -c` scaffolds use the same pattern, so muscle memory works.
|
||||
:::
|
||||
|
||||
## Building a form
|
||||
|
||||
```php
|
||||
use Nibiru\Factory\Form;
|
||||
|
||||
Form::create(); // reset the static buffer
|
||||
|
||||
Form::addOpenDiv(['class' => 'form-group']);
|
||||
Form::addTypeLabel(['for' => 'login', 'value' => 'Username']);
|
||||
Form::addInputTypeText([
|
||||
'name' => 'login',
|
||||
'id' => 'login',
|
||||
'class' => 'form-control',
|
||||
'required' => 'required',
|
||||
'placeholder' => 'Type your name…',
|
||||
]);
|
||||
Form::addCloseDiv();
|
||||
|
||||
Form::addOpenDiv(['class' => 'form-group']);
|
||||
Form::addTypeLabel(['for' => 'password', 'value' => 'Password']);
|
||||
Form::addInputTypePassword([
|
||||
'name' => 'password',
|
||||
'id' => 'password',
|
||||
'class' => 'form-control',
|
||||
'required' => 'required',
|
||||
]);
|
||||
Form::addCloseDiv();
|
||||
|
||||
Form::addInputTypeSubmit(['value' => 'Sign in', 'class' => 'btn btn-primary']);
|
||||
|
||||
$html = Form::addForm([
|
||||
'method' => 'POST',
|
||||
'action' => '/login',
|
||||
'name' => 'loginForm',
|
||||
]);
|
||||
```
|
||||
|
||||
Pass `$html` to your view:
|
||||
|
||||
```php
|
||||
View::assign(['loginForm' => $html]);
|
||||
```
|
||||
|
||||
```smarty
|
||||
<div class="card-body">
|
||||
{$loginForm nofilter}
|
||||
</div>
|
||||
```
|
||||
|
||||
(`nofilter` because `Form::addForm()` already returns rendered HTML — Smarty otherwise escapes it.)
|
||||
|
||||
## Recipes
|
||||
|
||||
### Select with options
|
||||
|
||||
```php
|
||||
Form::addSelect(['name' => 'country', 'class' => 'form-control', 'id' => 'country']);
|
||||
Form::addSelectOption(['value' => 'at', 'label' => 'Austria']);
|
||||
Form::addSelectOption(['value' => 'lu', 'label' => 'Luxembourg']);
|
||||
Form::addSelectOption(['value' => 'us', 'label' => 'United States']);
|
||||
```
|
||||
|
||||
`addSelect` opens the `<select>` and queues option-collection state; each `addSelectOption` appends to it; the wrapping `</select>` is emitted automatically when the next non-option call happens.
|
||||
|
||||
### Radio group
|
||||
|
||||
```php
|
||||
foreach (['standard', 'admin', 'editor'] as $r) {
|
||||
Form::addInputTypeRadio([
|
||||
'name' => 'role', 'value' => $r, 'id' => "role-$r",
|
||||
]);
|
||||
Form::addTypeLabel(['for' => "role-$r", 'value' => ucfirst($r)]);
|
||||
}
|
||||
```
|
||||
|
||||
### File upload
|
||||
|
||||
```php
|
||||
Form::addTypeFileUpload([
|
||||
'name' => 'avatar',
|
||||
'accept' => 'image/png,image/jpeg',
|
||||
]);
|
||||
```
|
||||
|
||||
Don't forget `enctype` on the form:
|
||||
|
||||
```php
|
||||
Form::addForm([
|
||||
'method' => 'POST',
|
||||
'action' => '/profile/upload',
|
||||
'enctype' => 'multipart/form-data',
|
||||
]);
|
||||
```
|
||||
|
||||
### Hidden CSRF token
|
||||
|
||||
```php
|
||||
Form::addTypeHidden([
|
||||
'name' => 'csrf',
|
||||
'value' => bin2hex(random_bytes(16)),
|
||||
]);
|
||||
```
|
||||
|
||||
(Stash the value in `$_SESSION['csrf']` and verify on POST.)
|
||||
|
||||
## Layout helpers
|
||||
|
||||
`addOpenDiv` / `addCloseDiv` and `addOpenAny` / `addCloseAny` let you compose Bootstrap-style layouts inside the same fluent stream:
|
||||
|
||||
```php
|
||||
Form::addOpenDiv(['class' => 'row']);
|
||||
Form::addOpenDiv(['class' => 'col-md-6']);
|
||||
Form::addInputTypeText(['name' => 'first']);
|
||||
Form::addCloseDiv();
|
||||
Form::addOpenDiv(['class' => 'col-md-6']);
|
||||
Form::addInputTypeText(['name' => 'last']);
|
||||
Form::addCloseDiv();
|
||||
Form::addCloseDiv();
|
||||
```
|
||||
|
||||
`addOpenAny([…, 'tag' => 'fieldset'])` opens any other tag; `addCloseAny([…, 'tag' => 'fieldset'])` closes it.
|
||||
|
||||
## How it works under the hood
|
||||
|
||||
Form rendering is **string-based**, not DOM-based. Each type class lives at `core/c/type<X>.php` and contains an HTML template with placeholders:
|
||||
|
||||
```php
|
||||
// core/c/typetext.php
|
||||
private function _setElement() {
|
||||
$this->_element = '<input type="text" name="NAME" value="VALUE" '
|
||||
. 'placeholder="PLACEHOLDER" maxlength="MAXLENGTH" ID CLASS '
|
||||
. 'REQUIRED DATA>' . "\n";
|
||||
}
|
||||
```
|
||||
|
||||
The factory passes your `$attributes` array through `FormAttributes::loadAttributeValues()` which `str_replace`s each placeholder with the corresponding value. Empty values get the placeholder erased so attributes don't render with empty strings.
|
||||
|
||||
This is why **only known keys work** — `'name'`, `'value'`, `'placeholder'`, `'class'`, `'id'`, `'required'`, `'data'` are recognised; arbitrary keys are dropped silently.
|
||||
|
||||
## Pattern from production
|
||||
|
||||
The `formsController` on `thorax.nibiru-framework.com` builds its contact form in the constructor and assigns it to a property:
|
||||
|
||||
```php
|
||||
namespace Nibiru;
|
||||
use Nibiru\Adapter\Controller;
|
||||
use Nibiru\Factory\Form;
|
||||
|
||||
class formsController extends Controller {
|
||||
private string $form;
|
||||
|
||||
public function __construct() {
|
||||
parent::__construct();
|
||||
Form::create();
|
||||
Form::addTypeLabel(['value' => 'Full Name', 'for' => 'full-name']);
|
||||
Form::addInputTypeText([
|
||||
'name' => 'full-name',
|
||||
'id' => 'full-name',
|
||||
'required' => 'required',
|
||||
'class' => 'contacts-input form-control',
|
||||
]);
|
||||
// ...more fields...
|
||||
$this->form = Form::addForm([
|
||||
'name' => 'newregister',
|
||||
'method' => 'post',
|
||||
'action' => '/forms/submit',
|
||||
]);
|
||||
}
|
||||
|
||||
public function pageAction() {
|
||||
View::assign(['form' => $this->form]);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This keeps the controller's actions tiny — the form is a one-liner to render in the template.
|
||||
|
||||
## Common pitfalls
|
||||
|
||||
- **Forgetting `Form::create()`.** The buffer is static. Without `create()` you'll concatenate onto whatever was there last (including across requests in long-running PHP processes).
|
||||
- **Smarty escaping the HTML.** Add `nofilter` (or `|nofilter`) when echoing the rendered string.
|
||||
- **Custom attributes silently ignored.** Each type accepts a fixed set of placeholder keys. Use the `data` key for `data-*` attributes (which gets expanded), but truly arbitrary attributes are dropped.
|
||||
- **No automatic XSS escaping.** The form layer is string-based. If you're rendering user input as a default `value`, escape it yourself before passing to the factory.
|
||||
- **`addInputType…` vs `addType…` confusion.** When in doubt, look at the `core/c/type<X>.php` filename — if the type's HTML element is `<input type="X">` use `addInputType<X>`, otherwise `addType<X>`. `addSelect` is the lone exception (just `addSelect`, no prefix).
|
||||
|
||||
## Form validation
|
||||
|
||||
Nibiru does not ship server-side validation. Common patterns:
|
||||
|
||||
- `Respect/Validation` for declarative checks (already in many production Nibiru apps).
|
||||
- A module with a `validate()` plugin per form type.
|
||||
- HTML5 `required`, `pattern`, `min`/`max` for the first line of defence.
|
||||
165
docs/src/content/docs/en/core/models.md
Normal file
165
docs/src/content/docs/en/core/models.md
Normal file
@@ -0,0 +1,165 @@
|
||||
---
|
||||
title: Models
|
||||
description: How Nibiru auto-generates model classes from your database schema, and how to extend them.
|
||||
---
|
||||
|
||||
Nibiru's model layer is *schema-first*. You don't write models by hand — the framework reads your database and generates one PHP class per table on every boot.
|
||||
|
||||
## How generation works
|
||||
|
||||
When `[GENERATOR] database = true` in your INI, the dispatcher runs `new Model(false)` on each request, which:
|
||||
|
||||
1. Connects with the active driver.
|
||||
2. Lists tables in the configured database (`information_schema.tables` for PG, `SHOW TABLES` for MySQL).
|
||||
3. For each table, writes `application/model/<table>.php` containing a class that extends the relevant `Db` adapter and embeds a `TABLE` constant describing the columns.
|
||||
|
||||
Re-running is cheap: the generator overwrites only when the schema has changed, so checked-in models keep their handwritten methods *if* you turn `database = false` after the first run.
|
||||
|
||||
```ini
|
||||
[GENERATOR]
|
||||
database = true ; regenerate models on each request
|
||||
database.overwrite = true ; if false, generator won't touch existing files
|
||||
```
|
||||
|
||||
In production set `database = false` so models aren't regenerated on every hit.
|
||||
|
||||
## A generated model
|
||||
|
||||
For a `users` table with columns `user_id, user_login, user_pass, user_email`:
|
||||
|
||||
```php
|
||||
<?php
|
||||
namespace Nibiru\Model;
|
||||
use Nibiru\Adapter\MySQL\Db;
|
||||
|
||||
class users extends Db
|
||||
{
|
||||
const TABLE = [
|
||||
'table' => 'users',
|
||||
'field' => [
|
||||
'user_id' => 'user_id',
|
||||
'user_login' => 'user_login',
|
||||
'user_pass' => 'user_pass',
|
||||
'user_email' => 'user_email',
|
||||
],
|
||||
];
|
||||
|
||||
public function __construct() {
|
||||
self::initTable(self::TABLE);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The `Db` adapter gives you simple CRUD helpers via `Pdo::` (or the active driver):
|
||||
|
||||
```php
|
||||
$users = new \Nibiru\Model\users();
|
||||
|
||||
// Read by primary key
|
||||
$row = \Nibiru\Pdo::fetchRowInArrayById('users', ['user_id' => 42]);
|
||||
|
||||
// Read all
|
||||
$all = \Nibiru\Pdo::fetchAll('SELECT * FROM users WHERE user_account_active = 1');
|
||||
|
||||
// Insert
|
||||
\Nibiru\Pdo::insert('users', [
|
||||
'user_login' => 'marduk',
|
||||
'user_email' => 'marduk@nibiru.local',
|
||||
]);
|
||||
|
||||
// Update
|
||||
\Nibiru\Pdo::update('users',
|
||||
['user_email' => 'new@nibiru.local'],
|
||||
['user_id' => 42]);
|
||||
|
||||
// Delete
|
||||
\Nibiru\Pdo::delete('users', ['user_id' => 42]);
|
||||
```
|
||||
|
||||
## Custom queries
|
||||
|
||||
The generated class is a plain PHP class — add methods freely:
|
||||
|
||||
```php
|
||||
namespace Nibiru\Model;
|
||||
use Nibiru\Adapter\MySQL\Db;
|
||||
use Nibiru\Pdo;
|
||||
|
||||
class documentation extends Db
|
||||
{
|
||||
const TABLE = [
|
||||
'table' => 'documentation',
|
||||
'field' => [
|
||||
'id' => 'id', 'title' => 'title', 'slug' => 'slug',
|
||||
'content' => 'content', 'category' => 'category',
|
||||
'version' => 'version',
|
||||
],
|
||||
];
|
||||
|
||||
public function __construct() { self::initTable(self::TABLE); }
|
||||
|
||||
public function getBySlug(string $slug): ?array {
|
||||
return Pdo::fetchRowInArrayById(
|
||||
self::TABLE['table'],
|
||||
[self::TABLE['field']['slug'] => $slug]
|
||||
) ?: null;
|
||||
}
|
||||
|
||||
public function search(string $query): array {
|
||||
$sql = 'SELECT * FROM ' . self::TABLE['table']
|
||||
. ' WHERE title LIKE :q OR content LIKE :q ORDER BY title';
|
||||
return Pdo::fetchAll($sql, [':q' => '%' . $query . '%']);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
If the generator runs again with `database.overwrite = true` it will replace this file. Keep custom methods in a child class or set `database.overwrite = false` after first generation.
|
||||
|
||||
## Pattern: thin model + module plugin
|
||||
|
||||
For complex domains (auth, billing, analytics), the showcase apps put query methods on a **module plugin** rather than the bare model. The plugin owns the business rules; the model is just a typed handle to the table:
|
||||
|
||||
```
|
||||
application/module/users/
|
||||
├── plugins/
|
||||
│ └── user.php # User::isAuthorized(), User::loginForm(), …
|
||||
└── traits/
|
||||
└── userForm.php
|
||||
```
|
||||
|
||||
```php
|
||||
namespace Nibiru\Module\Users\Plugin;
|
||||
use Nibiru\Model\users;
|
||||
|
||||
class User {
|
||||
private users $usersModel;
|
||||
public function __construct() { $this->usersModel = new users(); }
|
||||
|
||||
public function isAuthorized(): bool {
|
||||
return isset($_SESSION['auth']['user_id']);
|
||||
}
|
||||
|
||||
public function findByLogin(string $login): ?array {
|
||||
return \Nibiru\Pdo::fetchRow(
|
||||
'SELECT * FROM users WHERE user_login = :login',
|
||||
[':login' => $login]
|
||||
) ?: null;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This keeps controllers thin (`$this->user->isAuthorized()`) while leaving the generated model untouched.
|
||||
|
||||
## Multi-driver gotchas
|
||||
|
||||
The generated `Db` adapter is driver-specific. If you switch from MySQL to PostgreSQL, regenerate models so they extend the right base class:
|
||||
|
||||
- MySQL / PDO → `Nibiru\Adapter\MySQL\Db`
|
||||
- PostgreSQL (libpq) → `Nibiru\Adapter\PostgreSQL\Db`
|
||||
- ODBC → `Nibiru\Adapter\Odbc\Db`
|
||||
|
||||
Same query helpers, different adapter under the hood.
|
||||
|
||||
## When to skip the generator
|
||||
|
||||
Read-only databases, vendor systems, or any schema you don't control: set `database = false`, hand-write minimal model classes that match the columns you actually use, and check them in.
|
||||
273
docs/src/content/docs/en/core/modules.md
Normal file
273
docs/src/content/docs/en/core/modules.md
Normal file
@@ -0,0 +1,273 @@
|
||||
---
|
||||
title: Modules
|
||||
description: Building Nibiru modules — the second M in MMVC. Traits, plugins, interfaces, settings, observers.
|
||||
---
|
||||
|
||||
A **module** is a self-contained domain unit. It owns its config, its plugins (services), its traits (reusable methods), its interfaces, and optionally an MVC slice of its own. Modules are how Nibiru avoids the *fat-controller* problem without a service container.
|
||||
|
||||
<figure>
|
||||
<img src="/img/modules-anatomy.png" alt="Botanical anatomy of a Nibiru module, drawn as the layered petals of a lotus — outer petals, middle petals, inner petals, sepal layer, receptacle." />
|
||||
<figcaption>A module, layer by layer.</figcaption>
|
||||
</figure>
|
||||
|
||||
## Anatomy
|
||||
|
||||
```
|
||||
application/module/<name>/
|
||||
├── <name>.php # main class (implements IModule, optionally SplSubject)
|
||||
├── interfaces/ # contracts for plugins / external consumers
|
||||
│ └── <name>.php
|
||||
├── plugins/ # stateless services usable from controllers
|
||||
│ ├── <thing>.php
|
||||
│ └── <other>.php
|
||||
├── settings/ # auto-discovered .ini files
|
||||
│ ├── <name>.ini
|
||||
│ └── <name>.production.ini
|
||||
└── traits/ # reusable method groups
|
||||
└── <name>.php
|
||||
```
|
||||
|
||||
The `Registry` walks `application/module/` at boot, discovers each module's `settings/*.ini`, parses the section matching the module name (uppercased), and caches it for lookup via `Registry::getInstance()->loadModuleConfigByName('users')`.
|
||||
|
||||
## A minimal module
|
||||
|
||||
```php
|
||||
<?php
|
||||
namespace Nibiru\Module\Users;
|
||||
|
||||
use Nibiru\Module as ModuleAdapter;
|
||||
use Nibiru\Interfaces\IModule;
|
||||
use Nibiru\Registry;
|
||||
|
||||
class Users extends ModuleAdapter implements IModule, \SplSubject
|
||||
{
|
||||
use Traits\Users;
|
||||
|
||||
const CONFIG_MODULE_NAME = 'users';
|
||||
|
||||
protected static \stdClass $usersRegistry;
|
||||
protected \SplObjectStorage $observers;
|
||||
|
||||
public function __construct() {
|
||||
$this->setUsersRegistry();
|
||||
$this->observers = new \SplObjectStorage();
|
||||
}
|
||||
|
||||
public function attach(\SplObserver $o): void { $this->observers->attach($o); }
|
||||
public function detach(\SplObserver $o): void { $this->observers->detach($o); }
|
||||
public function notify(): void {
|
||||
foreach ($this->observers as $o) { $o->update($this); }
|
||||
}
|
||||
|
||||
protected function setUsersRegistry(): void {
|
||||
self::$usersRegistry = Registry::getInstance()
|
||||
->loadModuleConfigByName(self::CONFIG_MODULE_NAME);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The `IModule` interface is intentionally a marker — actual behaviour is all in your traits and plugins.
|
||||
|
||||
## Plugins: stateless services
|
||||
|
||||
A **plugin** is a class controllers can instantiate to access module functionality:
|
||||
|
||||
```php
|
||||
<?php
|
||||
// application/module/users/plugins/user.php
|
||||
namespace Nibiru\Module\Users\Plugin;
|
||||
|
||||
use Nibiru\Module\Users\Users;
|
||||
use Nibiru\Pdo;
|
||||
|
||||
class User extends Users
|
||||
{
|
||||
public function isAuthorized(): bool {
|
||||
return isset($_SESSION['auth']['user_id']);
|
||||
}
|
||||
|
||||
public function checkForStandardUser(): bool {
|
||||
return $this->isAuthorized()
|
||||
&& ($_SESSION['auth']['role'] ?? '') === 'standard';
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
In a controller:
|
||||
|
||||
```php
|
||||
$this->user = new \Nibiru\Module\Users\Plugin\User();
|
||||
if (!$this->user->isAuthorized()) {
|
||||
View::forwardTo('/login');
|
||||
}
|
||||
```
|
||||
|
||||
Plugins inherit from the module class, so they share access to the registry, settings, and observer machinery.
|
||||
|
||||
## Traits: reusable methods
|
||||
|
||||
A **trait** carries reusable method bodies the module class wants. Common pattern: form factories.
|
||||
|
||||
```php
|
||||
<?php
|
||||
// application/module/users/traits/users.php
|
||||
namespace Nibiru\Module\Users\Traits;
|
||||
|
||||
use Nibiru\Form;
|
||||
|
||||
trait Users
|
||||
{
|
||||
public function loginForm(): string {
|
||||
Form::create();
|
||||
Form::addOpenDiv(['class' => 'form-group']);
|
||||
Form::addInputTypeText([
|
||||
'class' => 'form-control', 'name' => 'login',
|
||||
'placeholder' => 'Username',
|
||||
]);
|
||||
Form::addCloseDiv();
|
||||
Form::addOpenDiv(['class' => 'form-group']);
|
||||
Form::addInputTypePassword([
|
||||
'class' => 'form-control', 'name' => 'password',
|
||||
'placeholder' => 'Password',
|
||||
]);
|
||||
Form::addCloseDiv();
|
||||
return Form::addForm([
|
||||
'method' => 'POST',
|
||||
'action' => '/login',
|
||||
'name' => 'loginForm',
|
||||
]);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The main `Users` class brings it in via `use Traits\Users;`.
|
||||
|
||||
## Module settings (INI)
|
||||
|
||||
Each module can carry its own INI files. The Registry parses every `*.ini` in `settings/` and looks for a section named after the module (uppercased):
|
||||
|
||||
```ini
|
||||
; application/module/users/settings/users.ini
|
||||
[USERS]
|
||||
session.lifetime = 7200
|
||||
password.min.length = 12
|
||||
allowed.roles[] = "admin"
|
||||
allowed.roles[] = "editor"
|
||||
allowed.roles[] = "standard"
|
||||
```
|
||||
|
||||
Read it back from anywhere:
|
||||
|
||||
```php
|
||||
$cfg = \Nibiru\Registry::getInstance()->loadModuleConfigByName('users');
|
||||
$cfg->session_lifetime; // 7200
|
||||
$cfg->password_min_length; // 12
|
||||
$cfg->allowed_roles; // [admin, editor, standard]
|
||||
```
|
||||
|
||||
Environment overlays: a file named `users.production.ini` is preferred over `users.ini` when `APPLICATION_ENV=production`.
|
||||
|
||||
## The observer pattern
|
||||
|
||||
Modules implementing `SplSubject` can broadcast events to attached observers without coupling to them. From the showcase, the `analytics` module notifies any attached tracker on every page-view:
|
||||
|
||||
```php
|
||||
// in a controller
|
||||
$analytics = new \Nibiru\Module\Analytics\Analytics();
|
||||
$analytics->attach(new \Nibiru\Module\Analytics\Plugin\Matomo());
|
||||
$analytics->attach(new \Nibiru\Module\Analytics\Plugin\Plausible());
|
||||
|
||||
$analytics->trackPageView(); // internally calls notify()
|
||||
```
|
||||
|
||||
Each observer's `update($subject)` receives the analytics instance and pulls the event data it cares about.
|
||||
|
||||
## Real production modules
|
||||
|
||||
From the showcase apps:
|
||||
|
||||
- **`auth`** (TPMS) — session management, QR-code login, role-based access. Implements `SplSubject` so login/logout events can fan out to logging and audit modules.
|
||||
- **`cms`** (prod.maschinen-stockert.de) — content store keyed by *controller path* + language. Lets non-developers update site copy.
|
||||
- **`graph_mail`** (TPMS) — Microsoft Graph API wrapper for transactional email.
|
||||
- **`pdfgenerator`** (prod.maschinen-stockert.de) — generates machine catalogs from DB-driven templates.
|
||||
- **`machineryscout`** — Elasticsearch index management with traits for indexing, querying, and re-indexing.
|
||||
- **`assetmanager`** — central CSS/JS pipeline, used to swap themes per language.
|
||||
- **`analytics`** — observer-driven tracker fan-out (Matomo, etc.).
|
||||
|
||||
## Module registration: how the framework finds your module
|
||||
|
||||
A module folder on disk is necessary but not sufficient. The framework also has to know **to load it** — that's done with three position arrays in your `[AUTOLOADER]` config:
|
||||
|
||||
```ini
|
||||
; application/settings/config/settings.development.ini
|
||||
|
||||
[AUTOLOADER]
|
||||
iface.pos[] = "users" ; load application/module/users/interfaces/
|
||||
iface.pos[] = "billing" ; load application/module/billing/interfaces/
|
||||
trait.pos[] = "users" ; load application/module/users/traits/
|
||||
trait.pos[] = "billing"
|
||||
class.pos[] = "users" ; load application/module/users/users.php
|
||||
class.pos[] = "billing"
|
||||
class.plugin.pos[] = "" ; reserved
|
||||
```
|
||||
|
||||
The names are **lowercase folder names**, exactly as they appear under `application/module/`. The framework's `Auto::loader()` (called from the [Dispatcher](/en/core/dispatcher/)) walks each module in order, requiring its files.
|
||||
|
||||
### Plugin namespace convention
|
||||
|
||||
Plugin classes live under the **plural** namespace `Plugins`:
|
||||
|
||||
```php
|
||||
// application/module/billing/plugins/invoice.php
|
||||
namespace Nibiru\Module\Billing\Plugins; // ← plural
|
||||
class Invoice extends \Nibiru\Module\Billing\Billing { /* ... */ }
|
||||
```
|
||||
|
||||
Mismatch the namespace and you'll get autoloader misses. The CLI scaffold (`./nibiru -m billing`) generates the correct namespace for you.
|
||||
|
||||
### Settings discovery
|
||||
|
||||
You **don't** register your module's INI files — the [Registry](/en/core/registry/) discovers them automatically by walking `application/module/<name>/settings/*.ini` after `[AUTOLOADER]` loads the module class. Each INI's `[<MODULE>]` (uppercased) section becomes available as:
|
||||
|
||||
```php
|
||||
$cfg = \Nibiru\Registry::getInstance()->loadModuleConfigByName('billing');
|
||||
$cfg->invoice_prefix; // [BILLING] invoice.prefix → property
|
||||
```
|
||||
|
||||
The Registry prefers `<module>.<env>.ini` (e.g. `billing.production.ini`) when `APPLICATION_ENV` matches.
|
||||
|
||||
### Migrations
|
||||
|
||||
If your module has database tables, drop SQL files in `application/settings/config/database/` numbered after the existing range. Convention used by the AI module:
|
||||
|
||||
```
|
||||
200-ai_rag_collection.sql
|
||||
201-ai_rag_chunk.sql
|
||||
202-ai_conversation.sql
|
||||
203-ai_message.sql
|
||||
```
|
||||
|
||||
Run with `./nibiru -mi local`. The framework auto-generates models in `application/model/<table>.php` when `[GENERATOR] database = true` is set, ready to use via `\Nibiru\Pdo::fetchAll(…)`.
|
||||
|
||||
## Generating a module
|
||||
|
||||
```bash
|
||||
./nibiru -m billing
|
||||
```
|
||||
|
||||
Scaffolds:
|
||||
|
||||
```
|
||||
application/module/billing/
|
||||
├── billing.php
|
||||
├── interfaces/billing.php
|
||||
├── plugins/
|
||||
├── settings/billing.ini
|
||||
└── traits/
|
||||
```
|
||||
|
||||
Add the `-g` switch (`./nibiru -m billing -g`) when you want Graylog logging hooks pre-wired into the scaffold.
|
||||
|
||||
## When *not* to make a module
|
||||
|
||||
Don't promote every controller into a module. The threshold is: *do you have at least one trait, one plugin, and one INI key?* If yes, it earns its own module folder. If not, a shared trait file or a small `application/lib/` helper is plenty.
|
||||
105
docs/src/content/docs/en/core/pagination.md
Normal file
105
docs/src/content/docs/en/core/pagination.md
Normal file
@@ -0,0 +1,105 @@
|
||||
---
|
||||
title: Pagination
|
||||
description: URL-driven pagination with template helpers.
|
||||
---
|
||||
|
||||
Pagination in Nibiru is **URL-driven**. The page number is a URL segment (`/products/index/page/3`), not a query string. The `Pageination` class — note the spelling — reads it, computes offsets, and assigns a `pagination` array into Smarty.
|
||||
|
||||
## Wiring it in
|
||||
|
||||
```php
|
||||
use Nibiru\Pageination;
|
||||
use Nibiru\Model\products;
|
||||
|
||||
class productsController extends Controller
|
||||
{
|
||||
public function pageAction() {
|
||||
$products = new products();
|
||||
Pageination::setEntriesPerPage(25); // optional; default from INI
|
||||
Pageination::setTable($products);
|
||||
$rows = Pageination::loadTableAsArray();
|
||||
|
||||
View::assign(['products' => $rows]);
|
||||
}
|
||||
|
||||
public function navigationAction() {
|
||||
JsonNavigation::getInstance()->loadJsonNavigationArray();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`Pageination::setTable(...)` reads the URL, calculates offsets, and assigns the navigation metadata into the template via the `pagination` variable.
|
||||
|
||||
## Rendering in the template
|
||||
|
||||
```smarty
|
||||
{include 'shared/header.tpl'}
|
||||
<body>
|
||||
{include file="navigation.tpl"}
|
||||
|
||||
<main class="container">
|
||||
<table class="table">
|
||||
{foreach $products as $p}
|
||||
<tr><td>{$p.id}</td><td>{$p.name|escape}</td></tr>
|
||||
{/foreach}
|
||||
</table>
|
||||
|
||||
{include file="pageination.tpl"}
|
||||
</main>
|
||||
{include 'shared/footer.tpl'}
|
||||
</body>
|
||||
```
|
||||
|
||||
Or render inline:
|
||||
|
||||
```smarty
|
||||
<nav class="pagination">
|
||||
{if $pagination.previous}
|
||||
<a href="{$pagination.paginationPath}/page/{$pagination.previous}">←</a>
|
||||
{/if}
|
||||
|
||||
{foreach $pagination as $entry}
|
||||
{if isset($entry.page)}
|
||||
<a class="{if $entry.page == $pagination.current}active{/if}"
|
||||
href="{$pagination.paginationPath}/page/{$entry.page}">
|
||||
{$entry.page}
|
||||
</a>
|
||||
{/if}
|
||||
{/foreach}
|
||||
|
||||
{if $pagination.next}
|
||||
<a href="{$pagination.paginationPath}/page/{$pagination.next}">→</a>
|
||||
{/if}
|
||||
</nav>
|
||||
```
|
||||
|
||||
## URL format
|
||||
|
||||
```
|
||||
/<controller>/<action>/page/<N>
|
||||
```
|
||||
|
||||
Examples:
|
||||
|
||||
```
|
||||
/products/index/page/2
|
||||
/products/page/2 ; if action is omitted, "index" is implied
|
||||
/users/list/page/7
|
||||
```
|
||||
|
||||
The `paginationPath` template variable is the path *without* `/page/N` — append your own page number when generating links.
|
||||
|
||||
## Configuration
|
||||
|
||||
```ini
|
||||
[SETTINGS]
|
||||
entries.per.page = 25
|
||||
```
|
||||
|
||||
This is the global default; override per-controller with `Pageination::setEntriesPerPage()` before `setTable()`.
|
||||
|
||||
## Common pitfalls
|
||||
|
||||
- **Spelling.** The class is `Pageination`, the trait is `Attributes\Pageination`, the template is `pageination.tpl`. It's spelled this way throughout the framework. Don't fight it.
|
||||
- **`setTable()` order.** Call `setEntriesPerPage()` *before* `setTable()`, otherwise the offsets are computed against the default.
|
||||
- **Page-0 is invalid.** If a user requests `/page/0`, treat it as `1`. Nibiru does this internally, but if you build your own pagination links, normalise inputs.
|
||||
92
docs/src/content/docs/en/core/registry.md
Normal file
92
docs/src/content/docs/en/core/registry.md
Normal file
@@ -0,0 +1,92 @@
|
||||
---
|
||||
title: Registry
|
||||
description: How modules are auto-discovered and their configs cached for runtime access.
|
||||
---
|
||||
|
||||
The **Registry** is Nibiru's module-config cache. At boot it walks every directory under `application/module/`, parses each module's `settings/*.ini`, and exposes the parsed config through a single accessor. Think of it as the gravitational centre that keeps modules in orbit around each other.
|
||||
|
||||
## How discovery works
|
||||
|
||||
For each module under `application/module/<name>/settings/`:
|
||||
|
||||
1. The Registry iterates files matching `*.ini`.
|
||||
2. For each, it tries the **environment-specific** filename first (`<base>.<env>.<ext>`, e.g. `users.production.ini`); falls back to the base name (`users.ini`).
|
||||
3. It calls `parse_ini_file($path, true)` (multi-section).
|
||||
4. It looks for a section named after the module, *uppercased* — `[USERS]`, `[CMS]`, `[ANALYTICS]`.
|
||||
5. The parsed key/value pairs become an `\stdClass` keyed by the module name.
|
||||
|
||||
## Reading a module's config
|
||||
|
||||
```php
|
||||
$users = \Nibiru\Registry::getInstance()->loadModuleConfigByName('users');
|
||||
$users->session_lifetime;
|
||||
$users->password_min_length;
|
||||
$users->allowed_roles; // array
|
||||
```
|
||||
|
||||
Inside the module itself the convention is to wrap this in a setter:
|
||||
|
||||
```php
|
||||
class Users extends ModuleAdapter implements IModule
|
||||
{
|
||||
const CONFIG_MODULE_NAME = 'users';
|
||||
protected static \stdClass $usersRegistry;
|
||||
|
||||
protected function setUsersRegistry(): void {
|
||||
self::$usersRegistry = Registry::getInstance()
|
||||
->loadModuleConfigByName(self::CONFIG_MODULE_NAME);
|
||||
}
|
||||
|
||||
public static function lifetime(): int {
|
||||
return (int) self::$usersRegistry->session_lifetime;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Why uppercase section names?
|
||||
|
||||
INI section names are conventionally uppercased to make them visually distinct. The Registry assumes this, so a module called `cms` looks for `[CMS]`. Always uppercase.
|
||||
|
||||
## Multi-INI per module
|
||||
|
||||
Nothing stops you from having more than one INI file in a module's `settings/` folder. The Registry parses them all and merges everything matching `[<MODULE>]` into the same `\stdClass`. Useful for splitting concerns:
|
||||
|
||||
```
|
||||
application/module/users/settings/
|
||||
├── users.ini # [USERS] base config
|
||||
├── users.smtp.ini # [USERS] mail-related keys
|
||||
└── users.production.ini # production override
|
||||
```
|
||||
|
||||
## When to use the Registry vs Config
|
||||
|
||||
| | `Config` | `Registry` |
|
||||
|---|---|---|
|
||||
| Source | `settings.<env>.ini` (one file) | per-module INIs (many files) |
|
||||
| Scope | app-wide framework config | module-specific config |
|
||||
| Sections | mixed (DATABASE, SETTINGS, ENGINE…) | one per module |
|
||||
| Lookup | `Config::getInstance()->getConfig()['DATABASE']['driver']` | `Registry::getInstance()->loadModuleConfigByName('users')` |
|
||||
|
||||
A module that needs to read `[DATABASE] driver` reaches for `Config`. A module that needs to read its own `[USERS] session_lifetime` reaches for `Registry`.
|
||||
|
||||
## Listing all loaded modules
|
||||
|
||||
```php
|
||||
$registry = \Nibiru\Registry::getInstance();
|
||||
foreach ($registry->getModuleNames() as $name) {
|
||||
$cfg = $registry->loadModuleConfigByName($name);
|
||||
echo "$name → " . print_r($cfg, true) . "\n";
|
||||
}
|
||||
```
|
||||
|
||||
Useful in a debug controller or admin dashboard.
|
||||
|
||||
## Reload semantics
|
||||
|
||||
Like `Config`, the Registry is a singleton populated **once** per request. If you change a module's INI you need to refresh the request to see the new values. There's a `destroy()` method on the Registry singleton if you really need to reload mid-request, but in normal flows you won't.
|
||||
|
||||
## Common pitfalls
|
||||
|
||||
- **Section name doesn't match the module folder.** Folder `users/`, expected section `[USERS]`. Mismatch → empty config.
|
||||
- **INI file extension.** Only `*.ini` is parsed. A `*.conf` or `*.config` file is ignored.
|
||||
- **Environment file beats base file silently.** If `users.production.ini` exists with `[USERS]` it shadows `users.ini` entirely — not merged. Keep both files in sync, or use one + an overlay.
|
||||
128
docs/src/content/docs/en/core/routing.md
Normal file
128
docs/src/content/docs/en/core/routing.md
Normal file
@@ -0,0 +1,128 @@
|
||||
---
|
||||
title: Routing
|
||||
description: How Nibiru maps URLs to controllers, actions and parameters — including SEO-friendly URL forms.
|
||||
---
|
||||
|
||||
Nibiru routing is **convention-based** with optional regex routes from `settings.<env>.ini` for special cases. There's no big route file to maintain.
|
||||
|
||||
## The default convention
|
||||
|
||||
```
|
||||
/<controller>/<action>/<param1>/<param2>/...
|
||||
```
|
||||
|
||||
Examples:
|
||||
|
||||
| URL | Controller | Action | $_REQUEST |
|
||||
|---|---|---|---|
|
||||
| `/` | `indexController` | (none) | — |
|
||||
| `/products` | `productsController` | (none) | — |
|
||||
| `/products/detail` | `productsController` | `detailAction` | — |
|
||||
| `/products/detail/42` | `productsController` | `detailAction` | `id => 42` |
|
||||
| `/users/edit/42` | `usersController` | `editAction` | `id => 42` |
|
||||
|
||||
Without an `_action`, only `navigationAction()` and `pageAction()` run. With `_action`, the named action runs in between.
|
||||
|
||||
## SEO URLs
|
||||
|
||||
Nibiru auto-detects SEO-friendly URLs without any configuration. The pattern:
|
||||
|
||||
```
|
||||
/<controller>/<slug>/<numeric-id>
|
||||
```
|
||||
|
||||
If the second segment is **not** a method name on the controller and the third segment is numeric, Nibiru rewrites the request internally to:
|
||||
|
||||
```
|
||||
/<controller>/detail/?id=<numeric-id>&slug=<slug>
|
||||
```
|
||||
|
||||
So a URL like `/maschine/marduk-gold-plating/42` reaches `maschineController::detailAction()` with `$_REQUEST['id'] === '42'` and `$_REQUEST['slug'] === 'marduk-gold-plating'`. This is exactly how the production e-commerce site `prod.maschinen-stockert.de` produces clean URLs without a custom router.
|
||||
|
||||
## Reading parameters
|
||||
|
||||
Anything past the action segment becomes a `$_REQUEST` key paired by position:
|
||||
|
||||
```php
|
||||
// /users/edit/42 → $_REQUEST['id'] = '42'
|
||||
public function editAction() {
|
||||
$id = (int) ($_REQUEST['id'] ?? 0);
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
For non-numeric or named parameters, prefer query strings:
|
||||
|
||||
```
|
||||
/users/search?q=marduk&page=2
|
||||
```
|
||||
|
||||
## Custom routes via INI
|
||||
|
||||
The `[NIBIRU_ROUTING]` section in `settings.<env>.ini` lets you map regex URL patterns to controller/action pairs and named params:
|
||||
|
||||
```ini
|
||||
[NIBIRU_ROUTING]
|
||||
; Map /api/v1/products/42 to apiController::productsAction with id=42
|
||||
api.v1.products.pattern = "^/api/v1/products/(\d+)$"
|
||||
api.v1.products.controller = "api"
|
||||
api.v1.products.action = "products"
|
||||
api.v1.products.params[] = "id"
|
||||
```
|
||||
|
||||
When a URL matches the pattern, capture groups are assigned to the named `params[]` keys (in order) as `$_REQUEST` entries.
|
||||
|
||||
## Routing helpers
|
||||
|
||||
```php
|
||||
Router::getInstance()->currentPage(); // 'products'
|
||||
Router::getInstance()->tplName(); // 'products' (controller stem for templates)
|
||||
Router::getInstance()->getController(); // alias for currentPage()
|
||||
```
|
||||
|
||||
These are useful inside controllers and templates:
|
||||
|
||||
```smarty
|
||||
<a href="/products" class="{if Router::currentPage() == 'products'}active{/if}">
|
||||
Products
|
||||
</a>
|
||||
```
|
||||
|
||||
(In Smarty you'd use a Smarty plugin or a pre-assigned variable; the helper itself is for PHP.)
|
||||
|
||||
## Forwarding
|
||||
|
||||
To redirect at the framework level (sets the right HTTP headers and exits):
|
||||
|
||||
```php
|
||||
View::forwardTo('/login');
|
||||
```
|
||||
|
||||
For a JSON response (sets `Content-Type: application/json`):
|
||||
|
||||
```php
|
||||
View::forwardToJsonHeader();
|
||||
View::assign(['data' => ['ok' => true]]);
|
||||
```
|
||||
|
||||
This pattern is used heavily by API endpoints in production — see the `apiController` in `data.maschinen-stockert.de`.
|
||||
|
||||
## Pagination URLs
|
||||
|
||||
Nibiru's [pagination](/core/pagination/) expects URLs of the form:
|
||||
|
||||
```
|
||||
/<controller>/<action>/page/<N>
|
||||
```
|
||||
|
||||
The `Pageination` class parses the trailing `page/N` segment, so any route format that preserves it is fine.
|
||||
|
||||
## Trailing slashes & case
|
||||
|
||||
URLs are matched **case-sensitively**. `/Users/edit` and `/users/edit` will hit different controllers (the second exists, the first 404s). Trailing slashes are tolerated.
|
||||
|
||||
## Common pitfalls
|
||||
|
||||
- **Action collision with SEO URL.** If you name a method `aboutAction()` and try to use `/products/about/42` as an SEO URL, the SEO rewrite *won't* trigger, because `about` is a known action. Pick slugs that don't collide with action names.
|
||||
- **Action without `_action`.** Just typing `/products/detail/` does **not** call `detailAction()` — `_action` must be set. The dispatcher does this automatically when the URL has at least two segments, but a stripped query string won't.
|
||||
- **`index` is the root controller.** `/` → `indexController`. There is no separate "home" route.
|
||||
183
docs/src/content/docs/en/core/views.md
Normal file
183
docs/src/content/docs/en/core/views.md
Normal file
@@ -0,0 +1,183 @@
|
||||
---
|
||||
title: Views & Smarty
|
||||
description: How .tpl templates are resolved, the View::assign pipeline, caching, and shared partials.
|
||||
---
|
||||
|
||||
Views in Nibiru are **Smarty 3** templates. Each controller has a default template; nested actions can have their own. The `View` singleton wraps the Smarty engine and exposes one global helper: `View::assign()`.
|
||||
|
||||
## Where templates live
|
||||
|
||||
```
|
||||
application/view/
|
||||
├── templates/ # source .tpl files (you write these)
|
||||
│ ├── index.tpl # → indexController::pageAction()
|
||||
│ ├── products.tpl # → productsController::pageAction()
|
||||
│ ├── products/
|
||||
│ │ └── detail.tpl # → productsController::detailAction()
|
||||
│ ├── shared/
|
||||
│ │ ├── header.tpl
|
||||
│ │ ├── footer.tpl
|
||||
│ │ └── meta.tpl
|
||||
│ ├── navigation.tpl
|
||||
│ └── pageination.tpl # (note the spelling)
|
||||
├── templates_c/ # Smarty compile cache (auto)
|
||||
├── cache/ # rendered HTML cache (when caching=true)
|
||||
└── mockup/ # static design mockups
|
||||
```
|
||||
|
||||
`templates_c/` is created and managed by Smarty. **It must be writable by your web server user.** `cache/` is only used when `[ENGINE] caching = true`.
|
||||
|
||||
## Resolution rules
|
||||
|
||||
The Display layer resolves the template path from the matched controller:
|
||||
|
||||
- For `pageAction()` → `templates/<controller>.tpl`.
|
||||
- For a named action `fooAction()` → `templates/<controller>/foo.tpl` if it exists, otherwise `templates/<controller>.tpl`.
|
||||
|
||||
If neither resolves, the [soft 404](/core/dispatcher/#soft-404) error template is rendered.
|
||||
|
||||
## A minimal template
|
||||
|
||||
```smarty
|
||||
{include 'shared/header.tpl'}
|
||||
<body>
|
||||
{include file="navigation.tpl"}
|
||||
|
||||
<main class="container">
|
||||
<h1>{$title|escape}</h1>
|
||||
<ul>
|
||||
{foreach $products as $p}
|
||||
<li><a href="/products/detail/{$p.id}">{$p.name|escape}</a></li>
|
||||
{/foreach}
|
||||
</ul>
|
||||
</main>
|
||||
|
||||
{include 'shared/footer.tpl'}
|
||||
</body>
|
||||
```
|
||||
|
||||
`{include 'shared/header.tpl'}` and `{include file="navigation.tpl"}` are both valid Smarty include forms.
|
||||
|
||||
## View::assign
|
||||
|
||||
`View::assign()` is how PHP data reaches templates. It's static, idempotent, and array-friendly:
|
||||
|
||||
```php
|
||||
View::assign(['title' => 'Products']);
|
||||
View::assign([
|
||||
'products' => $list,
|
||||
'count' => count($list),
|
||||
]);
|
||||
```
|
||||
|
||||
Later calls overwrite earlier ones for the same key. Inside templates these become `{$title}`, `{$products}`, `{$count}`.
|
||||
|
||||
There's no `View::display()` you call manually — the dispatcher invokes `Display::display()` automatically after `pageAction()` returns.
|
||||
|
||||
## Shared CSS/JS injection
|
||||
|
||||
The convention used across the showcase apps is to push the configured asset list into the template:
|
||||
|
||||
```php
|
||||
View::assign([
|
||||
'css' => Config::getInstance()->getConfig()[View::NIBIRU_SETTINGS]['smarty.css'],
|
||||
'js' => Config::getInstance()->getConfig()[View::NIBIRU_SETTINGS]['smarty.js'],
|
||||
]);
|
||||
```
|
||||
|
||||
```smarty
|
||||
{* shared/header.tpl *}
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>{$title|escape}</title>
|
||||
{foreach $css as $stylesheet}
|
||||
<link rel="stylesheet" href="{$stylesheet}">
|
||||
{/foreach}
|
||||
</head>
|
||||
```
|
||||
|
||||
Then `[SETTINGS] smarty.css[]` in the INI file is the single source of truth for stylesheets.
|
||||
|
||||
## Caching
|
||||
|
||||
Caching is opt-in:
|
||||
|
||||
```ini
|
||||
[ENGINE]
|
||||
caching = true
|
||||
```
|
||||
|
||||
When enabled, Smarty caches the rendered HTML in `application/view/cache/` with the lifetime `Smarty::CACHING_LIFETIME_CURRENT` (default ≈ 1 hour). Clear the cache with:
|
||||
|
||||
```bash
|
||||
./nibiru -cache-clear
|
||||
```
|
||||
|
||||
This wipes both `templates_c/` and `cache/` safely.
|
||||
|
||||
:::caution
|
||||
Cached pages don't run your controller. Don't cache pages with user-specific content or CSRF tokens.
|
||||
:::
|
||||
|
||||
## Smarty essentials
|
||||
|
||||
Things you'll reach for daily:
|
||||
|
||||
```smarty
|
||||
{* variables *}
|
||||
{$user.name}
|
||||
{$products[0].title}
|
||||
|
||||
{* iteration *}
|
||||
{foreach $items as $item}
|
||||
...
|
||||
{foreachelse}
|
||||
No items.
|
||||
{/foreach}
|
||||
|
||||
{* conditionals *}
|
||||
{if $count > 0}…{else}…{/if}
|
||||
|
||||
{* string filters *}
|
||||
{$body|escape} {* HTML escape *}
|
||||
{$date|date_format:"%Y-%m-%d"}
|
||||
{$price|string_format:"%.2f"}
|
||||
|
||||
{* includes *}
|
||||
{include file="shared/header.tpl" title=$title}
|
||||
|
||||
{* assigning *}
|
||||
{assign var="now" value=time()}
|
||||
```
|
||||
|
||||
## JSON responses
|
||||
|
||||
When an action calls `View::forwardToJsonHeader()`, Smarty is bypassed and Nibiru emits the assigned `data` key as JSON. Handy for AJAX endpoints:
|
||||
|
||||
```php
|
||||
public function searchAction() {
|
||||
View::forwardToJsonHeader();
|
||||
$results = $this->index->search($_REQUEST['q'] ?? '');
|
||||
View::assign(['data' => $results]);
|
||||
}
|
||||
```
|
||||
|
||||
## Navigation includes
|
||||
|
||||
`{include file="navigation.tpl"}` reads from the JSON file configured in `[SETTINGS] navigation`. Production apps often load **multiple** named nav arrays:
|
||||
|
||||
```php
|
||||
public function navigationAction() {
|
||||
JsonNavigation::getInstance()->loadJsonNavigationArray('headnavigation');
|
||||
JsonNavigation::getInstance()->loadJsonNavigationArray('mainnavigation');
|
||||
JsonNavigation::getInstance()->loadJsonNavigationArray('footer');
|
||||
}
|
||||
```
|
||||
|
||||
Each named array becomes a Smarty variable of the same name, ready to render in the matching partial.
|
||||
|
||||
## Common gotchas
|
||||
|
||||
- **`templates_c/` permissions** — if Smarty can't write there, you'll get a fatal error. Run `./nibiru -s` once to fix.
|
||||
- **`{$variable}` with no value** — Smarty in default mode renders nothing rather than throwing. Turn on `error_reporting = E_ALL` and `[ENGINE] debug = true` while developing to spot typos.
|
||||
- **HTML escaping** — Smarty doesn't auto-escape. Use `|escape` for any user-controlled string.
|
||||
236
docs/src/content/docs/en/design/components.md
Normal file
236
docs/src/content/docs/en/design/components.md
Normal file
@@ -0,0 +1,236 @@
|
||||
---
|
||||
title: Components
|
||||
description: Buttons, cards, callouts, hero — copy-paste ready.
|
||||
---
|
||||
|
||||
## Primary button
|
||||
|
||||
A black-on-cream rectangle. Editorial. No gradient, no glow.
|
||||
|
||||
```html
|
||||
<a class="atelier-button atelier-button--primary" href="/en/start/">
|
||||
<span>Read the docs</span>
|
||||
<span class="atelier-button__arrow" aria-hidden="true">→</span>
|
||||
</a>
|
||||
```
|
||||
|
||||
```css
|
||||
.atelier-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.7rem 1.2rem;
|
||||
font-family: var(--nibiru-font-text);
|
||||
font-variation-settings: var(--nibiru-fv-body-bold);
|
||||
font-size: 0.92rem;
|
||||
letter-spacing: var(--nibiru-tracking-body);
|
||||
border-radius: var(--nibiru-radius-md);
|
||||
text-decoration: none;
|
||||
transition: transform 200ms var(--nibiru-ease-out),
|
||||
box-shadow 200ms var(--nibiru-ease-out);
|
||||
}
|
||||
|
||||
.atelier-button--primary {
|
||||
background: var(--nibiru-ink);
|
||||
color: var(--nibiru-paper);
|
||||
border: 1px solid var(--nibiru-ink);
|
||||
box-shadow: 0 1px 0 rgba(31, 27, 46, 0.4);
|
||||
}
|
||||
|
||||
.atelier-button--primary:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 8px 24px -8px rgba(31, 27, 46, 0.5);
|
||||
}
|
||||
|
||||
.atelier-button .atelier-button__arrow {
|
||||
transition: transform 240ms var(--nibiru-ease-out);
|
||||
}
|
||||
.atelier-button:hover .atelier-button__arrow {
|
||||
transform: translateX(3px);
|
||||
}
|
||||
```
|
||||
|
||||
## Ghost button
|
||||
|
||||
```css
|
||||
.atelier-button--ghost {
|
||||
background: transparent;
|
||||
color: var(--nibiru-ink);
|
||||
border: 1px solid var(--nibiru-ink-faint);
|
||||
}
|
||||
.atelier-button--ghost:hover {
|
||||
border-color: var(--nibiru-iris);
|
||||
color: var(--nibiru-iris-deep);
|
||||
}
|
||||
```
|
||||
|
||||
## Card
|
||||
|
||||
Flat, paper-tinted. Hover lifts 2px and brightens the border.
|
||||
|
||||
```html
|
||||
<article class="card">
|
||||
<h3 class="card-title">MMVC architecture</h3>
|
||||
<p>Modules wrap MVC with traits, plugins, interfaces and settings.</p>
|
||||
</article>
|
||||
```
|
||||
|
||||
```css
|
||||
.card {
|
||||
background: var(--nibiru-mist);
|
||||
border: 1px solid rgba(31, 27, 46, 0.10);
|
||||
border-radius: var(--nibiru-radius-lg);
|
||||
padding: 1.6rem 1.5rem;
|
||||
transition: transform 240ms var(--nibiru-ease-out),
|
||||
border-color 240ms,
|
||||
box-shadow 240ms;
|
||||
}
|
||||
.card:hover {
|
||||
transform: translateY(-2px);
|
||||
border-color: var(--nibiru-iris);
|
||||
box-shadow: 0 18px 40px -20px rgba(94, 84, 140, 0.35);
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-family: var(--nibiru-font-display);
|
||||
font-variation-settings: var(--nibiru-fv-display-medium);
|
||||
font-size: 1.05rem;
|
||||
letter-spacing: -0.018em;
|
||||
color: var(--nibiru-ink);
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
|
||||
.card p {
|
||||
color: var(--nibiru-ink-soft);
|
||||
font-size: 0.92rem;
|
||||
line-height: 1.6;
|
||||
margin: 0;
|
||||
}
|
||||
```
|
||||
|
||||
## Pull-quote callout
|
||||
|
||||
A single-pixel violet rule on the left. No box. No icon.
|
||||
|
||||
```html
|
||||
<aside class="callout callout--note">
|
||||
<span class="callout__title">Note</span>
|
||||
<p>Browse <code>/start/quick-start/</code> for a five-minute first build.</p>
|
||||
</aside>
|
||||
```
|
||||
|
||||
```css
|
||||
.callout {
|
||||
background: var(--nibiru-mist);
|
||||
border: 0;
|
||||
border-left: 2px solid var(--nibiru-iris);
|
||||
padding: 1rem 1.2rem 1rem 1.4rem;
|
||||
border-radius: 0;
|
||||
margin: 1.6rem 0;
|
||||
}
|
||||
.callout--tip { border-left-color: var(--nibiru-aurum); }
|
||||
.callout--caution { border-left-color: var(--nibiru-aurum); }
|
||||
.callout--danger { border-left-color: var(--nibiru-rose); }
|
||||
|
||||
.callout__title {
|
||||
font-family: var(--nibiru-font-text);
|
||||
font-variation-settings: var(--nibiru-fv-label);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: var(--nibiru-tracking-label);
|
||||
font-size: 0.72rem;
|
||||
color: var(--nibiru-ink-faint);
|
||||
display: block;
|
||||
margin-bottom: 0.45rem;
|
||||
}
|
||||
```
|
||||
|
||||
## Inline code
|
||||
|
||||
No box. A coloured underline tint that stays out of the way.
|
||||
|
||||
```css
|
||||
:not(pre) > code {
|
||||
font-family: var(--nibiru-font-mono);
|
||||
font-size: 0.86em;
|
||||
background: linear-gradient(to top,
|
||||
rgba(124, 112, 171, 0.16) 35%,
|
||||
transparent 35%);
|
||||
color: var(--nibiru-iris-deep);
|
||||
padding: 0 0.18em;
|
||||
border-radius: 2px;
|
||||
}
|
||||
```
|
||||
|
||||
## Hero with the lotus
|
||||
|
||||
Asymmetric two-column grid. Big editorial number behind the copy. The brand mark anchors the right side and breathes once every 18 seconds.
|
||||
|
||||
```html
|
||||
<section class="atelier-hero">
|
||||
<span class="atelier-hero__number" aria-hidden="true">01</span>
|
||||
|
||||
<div class="atelier-hero__grid">
|
||||
<div>
|
||||
<p class="atelier-hero__eyebrow">Modular MMVC PHP framework</p>
|
||||
<h1 class="atelier-hero__title">
|
||||
Create.<br>Invent.<br><em>Impress.</em>
|
||||
</h1>
|
||||
<p class="atelier-hero__lede">
|
||||
Nibiru is a modular PHP framework for builders who ship.
|
||||
</p>
|
||||
<div class="atelier-hero__cta">
|
||||
<a class="atelier-button atelier-button--primary" href="/en/start/">
|
||||
Read the docs <span aria-hidden="true">→</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="atelier-hero__art" aria-hidden="true">
|
||||
<img class="atelier-hero__mark" src="/img/nibiru-logo.png" alt="">
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
```
|
||||
|
||||
The full styles live in `src/styles/nibiru.css`.
|
||||
|
||||
## Oracle launcher
|
||||
|
||||
A 52px paper-coloured circle in the bottom-right. The lotus mark inside rotates on hover. No pulse. No glow.
|
||||
|
||||
```html
|
||||
<button id="oracle-launcher" type="button" aria-label="Open Oracle"></button>
|
||||
```
|
||||
|
||||
```css
|
||||
#oracle-launcher {
|
||||
position: fixed;
|
||||
bottom: 1.4rem; right: 1.4rem;
|
||||
width: 52px; height: 52px; border-radius: 50%;
|
||||
border: 1px solid rgba(31, 27, 46, 0.18);
|
||||
background: var(--nibiru-paper);
|
||||
box-shadow: 0 6px 24px -10px rgba(31, 27, 46, 0.35),
|
||||
0 1px 2px rgba(31, 27, 46, 0.08);
|
||||
cursor: pointer;
|
||||
transition: transform 220ms var(--nibiru-ease-out),
|
||||
box-shadow 220ms,
|
||||
border-color 220ms;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
|
||||
#oracle-launcher::before {
|
||||
content: '';
|
||||
width: 26px; height: 26px;
|
||||
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'><path d='M12 2 C 9 8, 9 16, 12 22 C 15 16, 15 8, 12 2 Z' fill='%237c70ab'/><path d='M2 12 C 8 9, 16 9, 22 12 C 16 15, 8 15, 2 12 Z' fill='%237db7dc'/></svg>");
|
||||
background-size: contain;
|
||||
transition: transform 400ms var(--nibiru-ease-out);
|
||||
}
|
||||
|
||||
#oracle-launcher:hover {
|
||||
transform: translateY(-2px);
|
||||
border-color: var(--nibiru-iris);
|
||||
}
|
||||
#oracle-launcher:hover::before {
|
||||
transform: rotate(60deg);
|
||||
}
|
||||
```
|
||||
87
docs/src/content/docs/en/design/motion.md
Normal file
87
docs/src/content/docs/en/design/motion.md
Normal file
@@ -0,0 +1,87 @@
|
||||
---
|
||||
title: Motion
|
||||
description: How things move on Nibiru sites — slowly, quietly, and not very often.
|
||||
---
|
||||
|
||||
## Principles
|
||||
|
||||
- **Less is more.** A single 18-second breath is better than three pulses, four spins and a fade-up.
|
||||
- **Transforms only.** Animate `transform` and `opacity`. Avoid `top`, `width`, `height` — they cause layout work.
|
||||
- **Honour `prefers-reduced-motion`.** Every keyframe in the system is gated by a media query.
|
||||
- **Hover earns motion.** Idle states are still. Motion happens when you *do* something — hover a card, click the Oracle.
|
||||
|
||||
## Token reference
|
||||
|
||||
| Token | Value | Use |
|
||||
|---|---|---|
|
||||
| `--nibiru-duration-fast` | 150ms | Inline transitions (links, hairline shifts) |
|
||||
| `--nibiru-duration-normal` | 220ms | Hover lifts, button transforms, card glow |
|
||||
| `--nibiru-duration-slow` | 400ms | Modal panels, oracle reveal |
|
||||
| `--nibiru-duration-breathe` | 18s | Hero lotus breathing |
|
||||
| `--nibiru-ease-out` | `cubic-bezier(0.2, 0.7, 0.2, 1)` | Default ease-out |
|
||||
| `--nibiru-ease-spring` | `cubic-bezier(0.34, 1.56, 0.64, 1)` | Reserved for the Oracle panel rise |
|
||||
|
||||
## The breath
|
||||
|
||||
A six-pixel rise, a fraction-of-a-degree rotate, over 18 seconds. The lotus is alive but not asking for attention.
|
||||
|
||||
```css
|
||||
@keyframes atelier-breathe {
|
||||
0%, 100% { transform: translateY(0) rotate(0deg); }
|
||||
50% { transform: translateY(-6px) rotate(0.6deg); }
|
||||
}
|
||||
.atelier-hero__mark {
|
||||
animation: atelier-breathe 18s ease-in-out infinite;
|
||||
}
|
||||
```
|
||||
|
||||
## The oracle rise
|
||||
|
||||
220ms entrance. A small `translateY` and `scale(0.99)` so it feels like it grew, not popped.
|
||||
|
||||
```css
|
||||
@keyframes oracle-rise {
|
||||
from { opacity: 0; transform: translateY(10px) scale(0.99); }
|
||||
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||
}
|
||||
#oracle-panel.is-open {
|
||||
animation: oracle-rise 220ms cubic-bezier(0.2, 0.7, 0.2, 1);
|
||||
}
|
||||
```
|
||||
|
||||
## The hover lift
|
||||
|
||||
Cards and the primary button rise 1–2px on hover, with a deeper shadow.
|
||||
|
||||
```css
|
||||
.card {
|
||||
transition: transform 240ms var(--nibiru-ease-out),
|
||||
box-shadow 240ms var(--nibiru-ease-out),
|
||||
border-color 240ms;
|
||||
}
|
||||
.card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--nibiru-shadow-lg);
|
||||
}
|
||||
```
|
||||
|
||||
## Reduced motion
|
||||
|
||||
```css
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.atelier-hero__mark,
|
||||
.card,
|
||||
#oracle-panel.is-open {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The site still **looks** right with motion off — animations are decoration, never information.
|
||||
|
||||
## Don't
|
||||
|
||||
- Don't pulse. The Oracle is a quiet ink-stamp, not a fire alarm.
|
||||
- Don't combine multiple animations on the same element. Pick one.
|
||||
- Don't animate longer than 400ms for UI feedback. User clicks should feel immediate.
|
||||
- Don't use spring/overshoot ease for the hero — only for small "thing-just-arrived" moments.
|
||||
58
docs/src/content/docs/en/design/overview.md
Normal file
58
docs/src/content/docs/en/design/overview.md
Normal file
@@ -0,0 +1,58 @@
|
||||
---
|
||||
title: The Atelier design system
|
||||
description: A neo-botanical visual language for the Nibiru framework — a lotus on cream paper, lit by morning light.
|
||||
---
|
||||
|
||||
The **Atelier** design system is the visual language behind this site and the Nibiru brand. It's drawn from the lotus mark in the brand logo and the warm cream the wordmark sits on. The system ships as portable design tokens — CSS, SCSS, JSON and Tailwind — so any Nibiru site, internal tool or partner project can adopt the look without reinventing it.
|
||||
|
||||
> A lotus on cream paper, lit by morning light.
|
||||
|
||||
## Five principles
|
||||
|
||||
1. **One family.** Every text element uses **Bricolage Grotesque** (variable font, Google Fonts). Different sizes use different optical-size axis values — display sizes are characterful, body sizes are calm. No serif, ever.
|
||||
2. **Cream, not white.** The page is `#f5f1e8` — warm vellum. Pure white is too cold. Pure black is too loud. We use a deep indigo-black `#1f1b2e` for text instead.
|
||||
3. **Two petals, one gold leaf.** Brand violet `#7c70ab` and brand sky-blue `#7db7dc` come straight from the lotus. A single restrained gold accent `#c9a96e` is reserved for moments that earn it.
|
||||
4. **Editorial spacing.** Generous whitespace, asymmetric layouts, lead paragraphs that breathe. Closer to a published monograph than a SaaS landing page.
|
||||
5. **Reduced motion.** Things move slowly when they move at all. The hero lotus breathes once every 18 seconds. No jitter, no flicker, no pulse.
|
||||
|
||||
## Get the tokens
|
||||
|
||||
```bash
|
||||
# CSS custom properties
|
||||
curl -O https://nibiru-framework.com/design-system/tokens.css
|
||||
|
||||
# SCSS variables and maps
|
||||
curl -O https://nibiru-framework.com/design-system/tokens.scss
|
||||
|
||||
# Tailwind preset
|
||||
curl -O https://nibiru-framework.com/design-system/tailwind.preset.js
|
||||
```
|
||||
|
||||
All tokens are namespaced (`--nibiru-*` / `nibiru.*` / `nibiru-*`).
|
||||
|
||||
## Adopt it in 12 lines
|
||||
|
||||
```html
|
||||
<link rel="stylesheet" href="https://nibiru-framework.com/design-system/tokens.css">
|
||||
<style>
|
||||
body { background: var(--nibiru-paper); color: var(--nibiru-ink);
|
||||
font-family: var(--nibiru-font-text);
|
||||
font-variation-settings: var(--nibiru-fv-body);
|
||||
letter-spacing: var(--nibiru-tracking-body); }
|
||||
h1 { font-variation-settings: var(--nibiru-fv-display-hero);
|
||||
letter-spacing: var(--nibiru-tracking-display);
|
||||
font-size: var(--nibiru-text-hero); }
|
||||
.cta { background: var(--nibiru-ink); color: var(--nibiru-paper);
|
||||
padding: 0.7rem 1.2rem;
|
||||
border-radius: var(--nibiru-radius-md); }
|
||||
</style>
|
||||
```
|
||||
|
||||
That's enough to be on-brand.
|
||||
|
||||
## What's documented here
|
||||
|
||||
- [Palette](/en/design/palette/) — every colour with its role.
|
||||
- [Typography](/en/design/typography/) — Bricolage's variable axes used in earnest.
|
||||
- [Components](/en/design/components/) — buttons, cards, callouts, the Oracle launcher.
|
||||
- [Motion](/en/design/motion/) — breathe, fade, no flash.
|
||||
54
docs/src/content/docs/en/design/palette.mdx
Normal file
54
docs/src/content/docs/en/design/palette.mdx
Normal file
@@ -0,0 +1,54 @@
|
||||
---
|
||||
title: Palette
|
||||
description: Every Nibiru colour, drawn directly from the lotus mark in the brand logo.
|
||||
---
|
||||
|
||||
import Swatch from '../../../../components/Swatch.astro';
|
||||
|
||||
## The brand petals
|
||||
|
||||
The two colours that everything else orbits, lifted from the lotus mark.
|
||||
|
||||
<Swatch token="--nibiru-iris" hex="#7c70ab" name="Iris" usage="Primary brand violet — the inner petals of the lotus. Used for accents, links, focus rings, hairlines." />
|
||||
<Swatch token="--nibiru-iris-deep" hex="#5e548c" name="Iris Deep" usage="Deeper violet for emphasis text and active states." />
|
||||
<Swatch token="--nibiru-iris-soft" hex="#b6adcd" name="Iris Soft" usage="Pale violet for subtle backgrounds and decorative ornaments." />
|
||||
<Swatch token="--nibiru-skyfall" hex="#7db7dc" name="Skyfall" usage="Brand sky-blue — the outer petals. Cool accents, dark-mode links." />
|
||||
<Swatch token="--nibiru-skyfall-deep" hex="#4a8fb7" name="Skyfall Deep" usage="Link emphasis, focus." />
|
||||
<Swatch token="--nibiru-skyfall-soft" hex="#c2dcec" name="Skyfall Soft" usage="Pale blue surface tint." />
|
||||
|
||||
## The page
|
||||
|
||||
There is **no pure white** in the system.
|
||||
|
||||
<Swatch token="--nibiru-paper" hex="#f5f1e8" name="Paper" usage="The page. The dominant colour of the design." />
|
||||
<Swatch token="--nibiru-mist" hex="#faf7f0" name="Mist" usage="Slightly lighter paper — elevated surfaces (cards, panels)." />
|
||||
<Swatch token="--nibiru-lavender" hex="#ece6f3" name="Lavender" usage="Surface tint with a violet whisper — sidebars, callouts." />
|
||||
<Swatch token="--nibiru-lavender-deep" hex="#ddd3eb" name="Lavender Deep" usage="Big background numerals on the splash page." />
|
||||
|
||||
## Text
|
||||
|
||||
<Swatch token="--nibiru-ink" hex="#1f1b2e" name="Ink" usage="Body text. Deep indigo-black, never pure black." />
|
||||
<Swatch token="--nibiru-ink-soft" hex="#4a4258" name="Ink Soft" usage="Lead paragraphs, secondary text." />
|
||||
<Swatch token="--nibiru-ink-faint" hex="#847b94" name="Ink Faint" usage="Captions, tabular labels, disabled state." />
|
||||
|
||||
## Restrained accents
|
||||
|
||||
Three colours used **rarely** — each earns its place by appearing in only one role.
|
||||
|
||||
<Swatch token="--nibiru-aurum" hex="#c9a96e" name="Aurum" usage="Gold leaf — the highest emphasis. The underline behind the word *Impress* on the splash hero. Use once per screen." />
|
||||
<Swatch token="--nibiru-rose" hex="#d68a8a" name="Rose" usage="The single danger note. Used by the danger-callout border." />
|
||||
<Swatch token="--nibiru-moss" hex="#94a96e" name="Moss" usage="The single success / valid note. Reserved for status indicators." />
|
||||
|
||||
## Dark mode
|
||||
|
||||
When the user toggles dark mode, the system flips to a twilight palette but keeps the petals unchanged.
|
||||
|
||||
<Swatch token="--nibiru-dark-bg" hex="#181428" name="Twilight" usage="Dark-mode page background." />
|
||||
<Swatch token="--nibiru-dark-surface" hex="#1f1933" name="Aubergine" usage="Dark-mode surface for cards and panels." />
|
||||
|
||||
## Don't
|
||||
|
||||
- **No purple gradients on white.** That's the AI-slop aesthetic this system explicitly rejects.
|
||||
- **No pure black or pure white.** Use Ink and Paper.
|
||||
- **Don't dilute the gold.** Aurum should never be next to another accent. One gold-leaf moment per screen, max.
|
||||
- **Don't introduce new accents.** The palette is small on purpose.
|
||||
112
docs/src/content/docs/en/design/typography.md
Normal file
112
docs/src/content/docs/en/design/typography.md
Normal file
@@ -0,0 +1,112 @@
|
||||
---
|
||||
title: Typography
|
||||
description: One variable family used in earnest — Bricolage Grotesque, axes opsz 12–96 and weight 200–800.
|
||||
---
|
||||
|
||||
## One family, three axes
|
||||
|
||||
Nibiru uses **Bricolage Grotesque** (free, Google Fonts) for every piece of text. The trick is that it's a **variable font** with two meaningful axes:
|
||||
|
||||
- **Optical size (`opsz`, 12–96)** — display sizes get characterful, narrowed, slightly squarer; body sizes get calmer and more open.
|
||||
- **Weight (`wght`, 200–800)** — full range available.
|
||||
- **Italic** — true italic, not slanted.
|
||||
|
||||
So a single font file gives us a display face *and* a body face. No need to load extra fonts.
|
||||
|
||||
```css
|
||||
font-family: 'Bricolage Grotesque', system-ui, sans-serif;
|
||||
font-variation-settings: 'opsz' 96, 'wght' 600; /* display */
|
||||
font-variation-settings: 'opsz' 14, 'wght' 400; /* body */
|
||||
```
|
||||
|
||||
Code uses **JetBrains Mono** because it's quietly excellent at code and pairs well.
|
||||
|
||||
## Variation presets (the building blocks)
|
||||
|
||||
| Token | Variation | Use |
|
||||
|---|---|---|
|
||||
| `--nibiru-fv-display-hero` | `'opsz' 96, 'wght' 600` | Hero H1 |
|
||||
| `--nibiru-fv-display-large` | `'opsz' 64, 'wght' 600` | H2 |
|
||||
| `--nibiru-fv-display-medium` | `'opsz' 36, 'wght' 600` | H3 |
|
||||
| `--nibiru-fv-heading-small` | `'opsz' 18, 'wght' 600` | H4 |
|
||||
| `--nibiru-fv-lead` | `'opsz' 24, 'wght' 400` | Lead paragraph |
|
||||
| `--nibiru-fv-body` | `'opsz' 14, 'wght' 400` | Body |
|
||||
| `--nibiru-fv-body-bold` | `'opsz' 14, 'wght' 600` | Strong |
|
||||
| `--nibiru-fv-label` | `'opsz' 12, 'wght' 600` | Labels, eyebrow |
|
||||
|
||||
## Type scale
|
||||
|
||||
| Token | Size | Use |
|
||||
|---|---|---|
|
||||
| `--nibiru-text-xs` | 0.72rem | Eyebrow / labels |
|
||||
| `--nibiru-text-sm` | 0.85rem | Captions |
|
||||
| `--nibiru-text-md` | 0.92rem | UI controls, sidebar |
|
||||
| `--nibiru-text-base` | 1.00rem | Body |
|
||||
| `--nibiru-text-lg` | 1.18rem | Lead paragraph |
|
||||
| `--nibiru-text-xl` | 1.45rem | Section intros |
|
||||
| `--nibiru-text-2xl` | 2.00rem | H3 |
|
||||
| `--nibiru-text-3xl` | 2.60rem | H2 |
|
||||
| `--nibiru-text-hero` | clamp(2.6rem, 1.8rem + 4vw, 4.8rem) | H1, hero |
|
||||
|
||||
## Tracking
|
||||
|
||||
Tracking tightens as size grows — that's how you keep a hero feeling dense and a body feeling open.
|
||||
|
||||
| Token | Value | Use |
|
||||
|---|---|---|
|
||||
| `--nibiru-tracking-display` | −0.04em | Hero / H1 |
|
||||
| `--nibiru-tracking-heading` | −0.025em | H2, H3 |
|
||||
| `--nibiru-tracking-body` | −0.005em | Body |
|
||||
| `--nibiru-tracking-label` | 0.10em | Uppercase labels |
|
||||
| `--nibiru-tracking-eyebrow` | 0.22em | Hero eyebrow, section numbers |
|
||||
|
||||
## A hero, written out
|
||||
|
||||
```css
|
||||
h1.atelier-hero__title {
|
||||
font-family: var(--nibiru-font-display);
|
||||
font-variation-settings: var(--nibiru-fv-display-hero);
|
||||
font-size: var(--nibiru-text-hero);
|
||||
letter-spacing: var(--nibiru-tracking-display);
|
||||
line-height: 0.96;
|
||||
color: var(--nibiru-ink);
|
||||
max-width: 16ch;
|
||||
}
|
||||
|
||||
h1.atelier-hero__title em {
|
||||
font-style: normal;
|
||||
color: var(--nibiru-iris-deep);
|
||||
position: relative;
|
||||
}
|
||||
h1.atelier-hero__title em::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: auto 0 0.05em 0;
|
||||
height: 0.18em;
|
||||
background: var(--nibiru-aurum);
|
||||
opacity: 0.3;
|
||||
z-index: -1;
|
||||
}
|
||||
```
|
||||
|
||||
That's a hero. The italic word gets a single gold-leaf underline. Nothing else needs decoration.
|
||||
|
||||
## A body, written out
|
||||
|
||||
```css
|
||||
body, .prose p {
|
||||
font-family: var(--nibiru-font-text);
|
||||
font-variation-settings: var(--nibiru-fv-body);
|
||||
font-size: var(--nibiru-text-base);
|
||||
line-height: 1.7;
|
||||
letter-spacing: var(--nibiru-tracking-body);
|
||||
color: var(--nibiru-ink);
|
||||
}
|
||||
```
|
||||
|
||||
## Don't
|
||||
|
||||
- Don't use Bricolage Grotesque at the same OPSZ for hero and body — that wastes its variable axis.
|
||||
- Don't bold body text below `wght: 600`. Below 600 it doesn't read as bold.
|
||||
- Don't track display text positively. Tighten as size grows.
|
||||
- Don't use Bricolage Italic for whole sentences — use it for single words inside a heading. The "*Impress.*" trick on the splash page is the reference.
|
||||
43
docs/src/content/docs/en/index.mdx
Normal file
43
docs/src/content/docs/en/index.mdx
Normal file
@@ -0,0 +1,43 @@
|
||||
---
|
||||
title: Nibiru
|
||||
description: A modular MMVC PHP framework for the AI era. Open-source, PHP 8+, Apache 2.0.
|
||||
template: splash
|
||||
hero:
|
||||
eyebrow: Open source · PHP 8 · MMVC
|
||||
title: |
|
||||
An AI framework <em>that orbits</em> PHP<span class="period">.</span>
|
||||
tagline: |
|
||||
Nibiru is a structural framework for AI in PHP. The MMVC pattern — <em>Model, Module, View, Controller</em> — splits weights from capability so agents compose like code, not glue.
|
||||
actions:
|
||||
- text: Try the chat
|
||||
link: '#chat'
|
||||
icon: right-arrow
|
||||
variant: primary
|
||||
- text: Read the docs
|
||||
link: /en/start/what-is-nibiru/
|
||||
icon: right-arrow
|
||||
variant: minimal
|
||||
---
|
||||
|
||||
import CometTrail from '../../../components/CometTrail.astro';
|
||||
import MmvcStage from '../../../components/MmvcStage.astro';
|
||||
import MissionControl from '../../../components/MissionControl.astro';
|
||||
import LaunchSequence from '../../../components/LaunchSequence.astro';
|
||||
import SpacecraftGrid from '../../../components/SpacecraftGrid.astro';
|
||||
import EditorialContent from '../../../components/EditorialContent.astro';
|
||||
import LandingFooter from '../../../components/LandingFooter.astro';
|
||||
import ToTop from '../../../components/ToTop.astro';
|
||||
import LandingScripts from '../../../components/LandingScripts.astro';
|
||||
|
||||
<CometTrail />
|
||||
<MmvcStage />
|
||||
<MissionControl />
|
||||
<LaunchSequence />
|
||||
<SpacecraftGrid />
|
||||
<EditorialContent />
|
||||
<LandingFooter />
|
||||
<ToTop />
|
||||
|
||||
{/* Loads three.js + the original mockup scene code. MUST be the last node so
|
||||
every #id the scene targets exists in the DOM when the script runs. */}
|
||||
<LandingScripts />
|
||||
166
docs/src/content/docs/en/showcase/patterns.md
Normal file
166
docs/src/content/docs/en/showcase/patterns.md
Normal file
@@ -0,0 +1,166 @@
|
||||
---
|
||||
title: Patterns from Production
|
||||
description: Concrete patterns used across Nibiru apps in production — copy-paste-ready.
|
||||
---
|
||||
|
||||
Seven patterns that show up repeatedly across Nibiru production apps. Each is small, copy-paste-ready, and rooted in a real codebase.
|
||||
|
||||
## 1. Thin controller → module-plugin delegation
|
||||
|
||||
Keep controllers tiny. Push logic into module plugins.
|
||||
|
||||
```php
|
||||
// thin
|
||||
class erpController extends Controller {
|
||||
public function syncAction(): void {
|
||||
View::forwardToJsonHeader();
|
||||
$result = \Nibiru\Module\Erp\Plugin\Sync::run();
|
||||
View::assign(['data' => $result]);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```php
|
||||
// fat
|
||||
class Sync extends Erp {
|
||||
public static function run(): array {
|
||||
$svc = AlphaplanSyncService::getInstance();
|
||||
try {
|
||||
return ['success' => true, 'changes' => $svc->syncAbDocuments()];
|
||||
} catch (\Throwable $e) {
|
||||
return ['success' => false, 'error' => $e->getMessage()];
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The controller is reviewable in 5 seconds; the plugin is unit-testable.
|
||||
|
||||
## 2. CMS as content source
|
||||
|
||||
Decouple text from layout. Editors update copy via the CMS module's UI; templates stay developer-owned.
|
||||
|
||||
```php
|
||||
public function pageAction() {
|
||||
$path = $this->getController() . '/' . $this->getRequest('_action', 'page');
|
||||
foreach (Cms::init($this->getController())
|
||||
->loadCmsTemplateTextsByControllerPath($path, $this->language)
|
||||
as $t)
|
||||
{
|
||||
View::assign([
|
||||
$t['cms_template_texts_text_identifier']
|
||||
=> $t['cms_template_texts_text_content']
|
||||
]);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Templates reference the identifiers as if they were ordinary variables:
|
||||
|
||||
```smarty
|
||||
<h1>{$hero_title}</h1>
|
||||
<p>{$hero_intro}</p>
|
||||
```
|
||||
|
||||
## 3. Observer-driven analytics
|
||||
|
||||
Multiple trackers without controller coupling.
|
||||
|
||||
```php
|
||||
$analytics = new Analytics();
|
||||
$analytics->attach(new Plugin\Matomo());
|
||||
$analytics->attach(new Plugin\Plausible());
|
||||
$analytics->trackPageView(); // calls notify() internally
|
||||
```
|
||||
|
||||
Each observer's `update($subject)` pulls only the fields it cares about. Adding a tracker is a one-line change.
|
||||
|
||||
## 4. Multi-navigation composition
|
||||
|
||||
Build pages with several named nav arrays instead of one monolithic structure.
|
||||
|
||||
```php
|
||||
public function navigationAction() {
|
||||
foreach (['head', 'main', 'social', 'footer'] as $name) {
|
||||
JsonNavigation::getInstance()->loadJsonNavigationArray($name);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```smarty
|
||||
<header>{include file="navigation.tpl" array=$head}</header>
|
||||
<aside>{include file="navigation.tpl" array=$main}</aside>
|
||||
<footer>{include file="navigation.tpl" array=$footer}</footer>
|
||||
```
|
||||
|
||||
Each JSON file is small, scoped, easy to edit, and conflict-free in PRs.
|
||||
|
||||
## 5. JSON endpoints with `forwardToJsonHeader`
|
||||
|
||||
Standard contract for AJAX:
|
||||
|
||||
```php
|
||||
public function searchAction() {
|
||||
View::forwardToJsonHeader();
|
||||
$q = trim($_REQUEST['q'] ?? '');
|
||||
if (strlen($q) < 2) {
|
||||
View::assign(['data' => ['results' => []]]);
|
||||
return;
|
||||
}
|
||||
View::assign(['data' => [
|
||||
'results' => MachineryScout::index()->search($q),
|
||||
]]);
|
||||
}
|
||||
```
|
||||
|
||||
Headers are set automatically; no manual `header('Content-Type: application/json')`.
|
||||
|
||||
## 6. Multi-stage workflow via actions
|
||||
|
||||
State machines mapped onto controller actions:
|
||||
|
||||
```php
|
||||
class quotesController extends Controller {
|
||||
public function pageAction() { /* list view */ }
|
||||
public function detailAction() { /* one quote */ }
|
||||
public function acceptAction() { /* state transition: open → accepted */ }
|
||||
public function rejectAction() { /* state transition: open → rejected */ }
|
||||
public function archiveAction() { /* state transition: any → archived */ }
|
||||
}
|
||||
```
|
||||
|
||||
`/quotes/accept/42` runs `acceptAction()` with `$_REQUEST['id'] = 42`. Each transition is a tiny action; persistence and notification go through a `QuotesService` plugin.
|
||||
|
||||
## 7. Schema-first models with one custom method per intent
|
||||
|
||||
Generate the model from schema, then add intent-named methods that wrap your queries:
|
||||
|
||||
```php
|
||||
class users extends Db
|
||||
{
|
||||
const TABLE = ['table' => 'users', 'field' => [/* … */]];
|
||||
public function __construct() { self::initTable(self::TABLE); }
|
||||
|
||||
public function findByLogin(string $login): ?array {
|
||||
return Pdo::fetchRow('SELECT * FROM users WHERE user_login = :l',
|
||||
[':l' => $login]) ?: null;
|
||||
}
|
||||
|
||||
public function activeStandardUsers(): array {
|
||||
return Pdo::fetchAll(
|
||||
'SELECT * FROM users WHERE user_account_active = 1 AND user_role = :r',
|
||||
[':r' => 'standard']
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Future-you reading the call site sees `findByLogin($login)` — the intent — not raw SQL.
|
||||
|
||||
## Anti-patterns to avoid
|
||||
|
||||
- **Static-buffer leakage in forms.** Always `Form::create()` before building.
|
||||
- **Logic in `navigationAction()`.** It runs on every request, including JSON endpoints.
|
||||
- **Mass `View::assign()`** without a structured array. Use `View::assign(['…'])` once.
|
||||
- **Custom routes for what the SEO URL already does.** `/products/<slug>/<id>` is free.
|
||||
- **Editing generated models.** They get overwritten. Custom methods → child class or `database.overwrite = false`.
|
||||
137
docs/src/content/docs/en/showcase/projects.md
Normal file
137
docs/src/content/docs/en/showcase/projects.md
Normal file
@@ -0,0 +1,137 @@
|
||||
---
|
||||
title: In production
|
||||
description: Real Nibiru apps shipping real revenue. Maschinen Stockert sells industrial machinery in 12 countries on this framework.
|
||||
---
|
||||
|
||||
A framework worth using ships things. The flagship Nibiru deployment is the **Maschinen Stockert** group — a pair of repositories powering one of Austria's larger industrial-machinery e-commerce platforms.
|
||||
|
||||
The two repos share modules and a database; they split responsibility by audience.
|
||||
|
||||
---
|
||||
|
||||
<div class="showcase-plate">
|
||||
<img src="/img/showcase-tpms.png" alt="An industrial CNC machine on a factory floor at golden hour." />
|
||||
<div class="showcase-plate__body">
|
||||
<p class="showcase-plate__meta">10 controllers · 18 modules · 150 templates · 36,289 lines of PHP</p>
|
||||
<h3 class="showcase-plate__title">maschinen-stockert.de — public catalogue</h3>
|
||||
<p class="showcase-plate__desc">The buyer-facing site. Browse and search refurbished industrial machinery, view rich detail pages with SEO-friendly slugs, register for the annual Hausmesse trade show, request quotes.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
## maschinen-stockert.de — public catalogue
|
||||
|
||||
The public face: where industrial buyers land, search, and convert.
|
||||
|
||||
### What ships on it
|
||||
|
||||
- **Multilingual content**, with every visible string fetched from the `cms_template_texts` database keyed by `<controller>/<action>` + language. Editors update copy from the admin without a deploy.
|
||||
- **Elasticsearch-powered machine search**, with type-aware filtering — dimensions parsed from `"2500 × 1200 mm"` strings into numeric ranges so buyers can search by size.
|
||||
- **SEO-friendly URLs** — `/maschine/drehmaschine-2500/42` — generated from the machine name with German umlaut normalisation (`ä → ae`, `ß → ss`). The numeric ID always trails so the router can resolve a stale slug.
|
||||
- **Yumpu PDF flipbooks** for downloadable catalogues.
|
||||
- **Hausmesse module** for trade-show registration with role-aware access.
|
||||
|
||||
### One representative action
|
||||
|
||||
```php
|
||||
// application/controller/maschineController.php
|
||||
public function detailAction()
|
||||
{
|
||||
$machineId = $this->getRequest('id', true);
|
||||
if (!$machineId) {
|
||||
http_response_code(404);
|
||||
return;
|
||||
}
|
||||
|
||||
$controllerPath = $this->getController() . '/detail';
|
||||
$cmsTemplateTexts = Cms::init($this->getController())
|
||||
->loadCmsTemplateTextsByControllerPath($controllerPath, $this->language);
|
||||
|
||||
foreach ($cmsTemplateTexts as $t) {
|
||||
View::assign([
|
||||
$t['cms_template_texts_text_identifier']
|
||||
=> $t['cms_template_texts_text_content']
|
||||
]);
|
||||
}
|
||||
|
||||
try {
|
||||
$machine = Machine::init()->getMachine((int) $machineId);
|
||||
} catch (\Throwable $e) {
|
||||
$machine = null; // DB blip → page still renders with fallback.
|
||||
}
|
||||
|
||||
$machineName = $machine['ms_machines_name'] ?? "Maschine #$machineId";
|
||||
$protocol = (($_SERVER['HTTPS'] ?? '') === 'on') ? 'https' : 'http';
|
||||
|
||||
View::assign([
|
||||
'machine' => $machine,
|
||||
'pageTitle' => "$machineName - Maschinen Stockert",
|
||||
'metaDescription'=> "Details und Spezifikationen für $machineName",
|
||||
'canonicalUrl' => $protocol . '://' . $_SERVER['HTTP_HOST']
|
||||
. self::generateMachineSeoUrl($machineId, $machineName),
|
||||
]);
|
||||
}
|
||||
```
|
||||
|
||||
50 lines, no DI container, no validation pipeline, no middleware stack. Loads CMS-managed copy first (so a DB outage on machines doesn't kill the page), pulls the machine with a graceful fallback, generates SEO metadata always. The whole detail page renders with two database round-trips.
|
||||
|
||||
---
|
||||
|
||||
<div class="showcase-plate">
|
||||
<img src="/img/showcase-thorax.png" alt="A wall of well-worn leather technical books, one open in soft focus." />
|
||||
<div class="showcase-plate__body">
|
||||
<p class="showcase-plate__meta">36 controllers · 18 modules · 348 templates · 161 SQL migrations · 37,369 lines of PHP</p>
|
||||
<h3 class="showcase-plate__title">data.maschinen-stockert.de — admin & API</h3>
|
||||
<p class="showcase-plate__desc">The internal cockpit. Sales staff manage inventory, content, jobs, team pages, trade-show registrations. Developers and integrations call REST APIs to search machines, sync manufacturers, generate PDFs, hit Ollama for AI machine descriptions.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
## data.maschinen-stockert.de — admin & API
|
||||
|
||||
The same modules, three times the controllers and templates — because admin UIs and APIs need many entry points.
|
||||
|
||||
### What it does
|
||||
|
||||
- **Page-tree CMS**: editors build pages from a Smarty template; the `Parser` plugin scans `{$identifier}` placeholders in the template and auto-generates the editable-fields admin UI. The template *is* the form spec.
|
||||
- **Role-based ACL**: every admin controller's constructor calls `$this->user = new User(); $this->acl = new Acl(); $this->acl->init(); $this->user->validate();` — three lines, done. Sales has read-only on inventory; admins can edit; partners see only their assigned listings.
|
||||
- **Public-API whitelist**: `apiController` allows machine search, category fetch, team info, Ollama AI calls without auth, then requires auth for everything else. The whitelist is right in the constructor — no middleware ordering bugs.
|
||||
- **Machineryscout indexer**: the heaviest module. 2,200-line trait that pulls machines + attributes + images + documents from MySQL via `JSON_ARRAYAGG`, normalises types (`"2500 x 1200"` → `dimension_width: 2500.0, dimension_height: 1200.0`), substitutes placeholders for missing images, and ships rows into Elasticsearch.
|
||||
|
||||
### The Parser pattern — auto-generating editor UIs from a template
|
||||
|
||||
```php
|
||||
// application/controller/adminController.php
|
||||
$parser = Parser::init();
|
||||
$cmsEditable = $parser->parseSmartyTemplateByTemplateId($templateId);
|
||||
View::assign([
|
||||
'cmsEditable' => $cmsEditable,
|
||||
'cmsTemplateTextForm'=> Cms::textsEditingForm('/admin/texts/create/text/new'),
|
||||
]);
|
||||
```
|
||||
|
||||
Drop a new Smarty template in the system, the editor immediately knows which placeholders are editable. No "register the form fields" step.
|
||||
|
||||
---
|
||||
|
||||
## What's actually special, summarised
|
||||
|
||||
The five differentiators below are pulled from the codebases above. Each links to its evidence on the [Why Nibiru](/en/why-nibiru/) page.
|
||||
|
||||
| | What Nibiru does | What Laravel/Symfony does |
|
||||
|---|---|---|
|
||||
| Page copy | Loaded from DB by `<controller>/<action>` per request, editor-managed. | Hardcoded in Blade / translation JSON; deploy to change. |
|
||||
| Module composition | 13 traits per module, no DI. | Service providers + IoC container. |
|
||||
| ORM | Direct SQL, MySQL `JSON_ARRAYAGG`, `Pdo::fetchAll`. | Eloquent / Doctrine entities + lazy-loading proxies. |
|
||||
| Auth | 3 lines in the controller constructor. | Middleware stack + policy classes + gates. |
|
||||
| Events | `SplSubject` + `SplObserver` from PHP stdlib. | Custom event dispatcher + listener registry + queue. |
|
||||
|
||||
Read the [full breakdown with code references →](/en/why-nibiru/)
|
||||
|
||||
---
|
||||
|
||||
## What it took
|
||||
|
||||
Maschinen Stockert is a real, revenue-generating site selling machinery in 12 countries. It moved **161 timestamped SQL migrations** into production with no migration framework, **74,000 lines of PHP across two repos** with no service container, and **18 modules** that compose with traits instead of inheritance. The team that built and runs it is small.
|
||||
|
||||
That's the proof.
|
||||
|
||||
If you're shipping with a small team, on tight budget, into production where deploys and downtime cost real money — that's the Nibiru sweet spot. Read the [Quick Start](/en/start/quick-start/), then come back.
|
||||
137
docs/src/content/docs/en/start/deployment.md
Normal file
137
docs/src/content/docs/en/start/deployment.md
Normal file
@@ -0,0 +1,137 @@
|
||||
---
|
||||
title: Deploying the Docs Site
|
||||
description: Production deployment of nibiru-framework.com using jwilder/nginx-proxy and your own Ollama on neuronetz.ai.
|
||||
---
|
||||
|
||||
This page documents how the docs site is deployed in production. The setup uses **jwilder/nginx-proxy** for automatic Docker container routing, **letsencrypt-nginx-proxy-companion** for HTTPS, and **your own Ollama at api.neuronetz.ai** for the Oracle backend — no paid LLM API keys required.
|
||||
|
||||
## Topology
|
||||
|
||||
```
|
||||
Internet
|
||||
│
|
||||
▼
|
||||
┌──────────────────────┐
|
||||
│ jwilder/nginx-proxy │ ← reverse proxy on :80 / :443
|
||||
│ (network: nginx-proxy)│ terminates TLS
|
||||
└──────────┬───────────┘
|
||||
│ http://nibiru-docs:4321
|
||||
▼
|
||||
┌──────────────────────┐ ┌──────────────────────┐
|
||||
│ nibiru-docs │ ──────▶ │ api.neuronetz.ai │
|
||||
│ Astro Node SSR :4321 │ HTTPS │ Ollama (5× GPU) │
|
||||
│ Oracle endpoint │ │ qwen2.5-coder:14b │
|
||||
└──────────────────────┘ │ nomic-embed-text │
|
||||
└──────────────────────┘
|
||||
```
|
||||
|
||||
## Prerequisites on the host
|
||||
|
||||
```bash
|
||||
# 1) Create the shared external network (one time)
|
||||
docker network create nginx-proxy
|
||||
|
||||
# 2) Run nginx-proxy + acme-companion (one time)
|
||||
# See https://github.com/nginx-proxy/nginx-proxy for the canonical compose.
|
||||
|
||||
# 3) Pull the Oracle's models on neuronetz.ai (one time)
|
||||
curl https://api.neuronetz.ai/api/pull -d '{"name":"qwen2.5-coder:14b"}'
|
||||
curl https://api.neuronetz.ai/api/pull -d '{"name":"nomic-embed-text"}'
|
||||
```
|
||||
|
||||
## Files in `docs/`
|
||||
|
||||
| File | Purpose |
|
||||
|---|---|
|
||||
| `Dockerfile` | Multi-stage build: builds Oracle index against neuronetz.ai, builds Astro, prunes dev deps. |
|
||||
| `docker-compose.yml` | Production — `VIRTUAL_HOST=nibiru-framework.com`, joined to `nginx-proxy` network. |
|
||||
| `docker-compose.local.yml` | Local-test override — exposes `4321:4321`, drops nginx-proxy env vars. |
|
||||
| `.dockerignore` | Keeps `node_modules`, `.git`, etc. out of the build context. |
|
||||
| `.env.example` | Template — defaults to Ollama on neuronetz.ai, no API keys required. |
|
||||
|
||||
## Bring it up
|
||||
|
||||
```bash
|
||||
cd docs
|
||||
cp .env.example .env
|
||||
# defaults are fine for production unless you want to override the Ollama URL or models
|
||||
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
The first build runs `build-oracle-index.mjs` to embed the docs against your Ollama. Subsequent rebuilds are fast — Docker caches the dep layer; only changed chunks need re-embedding.
|
||||
|
||||
After ~30 seconds, jwilder/nginx-proxy picks up the new container, requests a Let's Encrypt cert, and routes `https://nibiru-framework.com` → `:4321`.
|
||||
|
||||
## Verify
|
||||
|
||||
```bash
|
||||
curl -s https://nibiru-framework.com/api/oracle | jq
|
||||
# {
|
||||
# "status": "ok",
|
||||
# "llm": { "provider": "ollama", "model": "qwen2.5-coder:14b", … },
|
||||
# "embed": { "provider": "ollama", "model": "nomic-embed-text", … },
|
||||
# "index": { "present": true, "chunks": 177, … }
|
||||
# }
|
||||
```
|
||||
|
||||
Or open the site in a browser, click the amber Oracle launcher, and ask a question.
|
||||
|
||||
## Update after a docs change
|
||||
|
||||
```bash
|
||||
git pull
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
The build re-runs the Oracle index against the freshest content; the new container starts on `:4321`; jwilder swaps the upstream; the old container is stopped — no noticeable downtime.
|
||||
|
||||
## Environment variables
|
||||
|
||||
| Var | Default | Used at | Purpose |
|
||||
|---|---|---|---|
|
||||
| `LLM_PROVIDER` | `ollama` | runtime | `ollama` (default) or `anthropic`. |
|
||||
| `OLLAMA_BASE_URL` | `https://api.neuronetz.ai` | build + runtime | Where to reach Ollama. |
|
||||
| `OLLAMA_CHAT_MODEL` | `qwen2.5-coder:14b` | runtime | Chat-completion model. |
|
||||
| `OLLAMA_EMBED_MODEL` | `nomic-embed-text` | build + runtime | Embedding model. |
|
||||
| `EMBED_PROVIDER` | `ollama` | build + runtime | `ollama` or `openai`. |
|
||||
| `ANTHROPIC_API_KEY` | — | runtime | Only used if `LLM_PROVIDER=anthropic`. |
|
||||
| `ANTHROPIC_MODEL` | `claude-haiku-4-5-20251001` | runtime | Override Claude model. |
|
||||
| `OPENAI_API_KEY` | — | build + runtime | Only used if `EMBED_PROVIDER=openai`. |
|
||||
| `ORACLE_TOP_K` | `6` | runtime | Chunks injected per Oracle response. |
|
||||
| `LETSENCRYPT_EMAIL` | `stephan.kasdorf@bittomine.com` | letsencrypt | Where Let's Encrypt sends expiry notices. |
|
||||
| `VIRTUAL_HOST` | `nibiru-framework.com,www.nibiru-framework.com` | nginx-proxy | Set in compose. |
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**`502 Bad Gateway`.** The upstream container failed to start. Check
|
||||
`docker logs nibiru-docs` — likely missing build artifact in `dist/server/entry.mjs`.
|
||||
|
||||
**Cert not issued.** Let's Encrypt rate-limits aggressively. Check
|
||||
`docker logs letsencrypt-nginx-proxy-companion` for the cause.
|
||||
|
||||
**Oracle answers without citations.** The embedding index is empty. Either
|
||||
`nomic-embed-text` isn't pulled on Ollama, or the build couldn't reach
|
||||
neuronetz.ai. Re-pull the model:
|
||||
```bash
|
||||
curl https://api.neuronetz.ai/api/pull -d '{"name":"nomic-embed-text"}'
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
**Oracle returns "the Oracle could not answer".** Check the chat model is pulled:
|
||||
```bash
|
||||
curl https://api.neuronetz.ai/api/tags | jq '.models[].name'
|
||||
```
|
||||
|
||||
**Want to fall back to Claude.** Set `LLM_PROVIDER=anthropic` and `ANTHROPIC_API_KEY` in `.env`, then `docker compose up -d`.
|
||||
|
||||
## Resource usage
|
||||
|
||||
| | Idle | Under Oracle load |
|
||||
|---|---|---|
|
||||
| RAM | ~120 MB | ~200 MB |
|
||||
| CPU | < 0.5% | ~5% |
|
||||
| Network | minimal | one HTTPS round-trip per question |
|
||||
| Disk | ~60 MB image + ~5 MB index | + corpus exports |
|
||||
|
||||
A 1 GB / 1 vCPU droplet handles the docs site comfortably alongside other services. The heavy lifting (LLM inference) happens on your GPU cluster, not the docs container.
|
||||
106
docs/src/content/docs/en/start/installation.md
Normal file
106
docs/src/content/docs/en/start/installation.md
Normal file
@@ -0,0 +1,106 @@
|
||||
---
|
||||
title: Installation
|
||||
description: Clone Nibiru, install dependencies, set permissions, run your first migration.
|
||||
---
|
||||
|
||||
## Requirements
|
||||
|
||||
- **PHP** ≥ 8.2 with these extensions: `pdo`, `gd`, `memcached`, `curl`.
|
||||
- **Composer** for PHP dependencies.
|
||||
- **A database**: MariaDB / MySQL ≥ 10.4, or PostgreSQL ≥ 13. ODBC if you're connecting to a non-native source.
|
||||
- **Smarty** (installed via Composer).
|
||||
- A web server with **mod_rewrite** (Apache) or equivalent (`vhost.conf` is included for nginx-style docroots).
|
||||
|
||||
## Clone & install
|
||||
|
||||
```bash
|
||||
git clone https://github.com/alllinux/Nibiru my-app
|
||||
cd my-app
|
||||
composer install
|
||||
```
|
||||
|
||||
Composer installs into `core/l/` (Nibiru uses an unusual `vendor-dir` to keep all framework code under `core/`).
|
||||
|
||||
## Configure
|
||||
|
||||
Copy the example INI file and edit your environment:
|
||||
|
||||
```bash
|
||||
cp application/settings/config/settings.development.ini.example \
|
||||
application/settings/config/settings.development.ini
|
||||
```
|
||||
|
||||
The minimum sections you need to set:
|
||||
|
||||
```ini
|
||||
[ENGINE]
|
||||
templates = "/../../application/view/templates/"
|
||||
templates_c = "/../../application/view/templates_c/"
|
||||
cache = "/../../application/view/cache/"
|
||||
caching = false
|
||||
debug = true
|
||||
error.controller = "error"
|
||||
|
||||
[SETTINGS]
|
||||
page.url = "https://my-app.local"
|
||||
navigation = "/../../application/settings/config/navigation/main.json"
|
||||
modules.path = "/../../application/module/"
|
||||
entries.per.page = 25
|
||||
smarty.css[] = "/public/css/app.css"
|
||||
smarty.js[] = "/public/js/app.js"
|
||||
timezone = "Europe/Vienna"
|
||||
|
||||
[DATABASE]
|
||||
driver = "pdo" ; one of: mysql, pdo, postgres, psql, postgresql
|
||||
hostname = "localhost"
|
||||
port = 3306
|
||||
username = "nibiru"
|
||||
password = "secret"
|
||||
basename = "nibiru_dev"
|
||||
encoding = "utf8mb4"
|
||||
is.active = true
|
||||
|
||||
[SECURITY]
|
||||
password_hash = "change-me-at-once"
|
||||
|
||||
[GENERATOR]
|
||||
database = true ; auto-generate models from DB tables
|
||||
```
|
||||
|
||||
`APPLICATION_ENV` selects which file is loaded — `settings.development.ini` by default.
|
||||
|
||||
```bash
|
||||
export APPLICATION_ENV=production # picks settings.production.ini
|
||||
```
|
||||
|
||||
## Bootstrap folders & permissions
|
||||
|
||||
```bash
|
||||
./nibiru -s
|
||||
```
|
||||
|
||||
This creates / fixes permissions on `application/view/templates_c/`, `application/view/cache/`, log directories, etc.
|
||||
|
||||
## Run your first migration
|
||||
|
||||
Migration files live in `application/settings/config/database/` as numbered SQL files (`001-acl.sql`, `002-account.sql`, …). Run them all with:
|
||||
|
||||
```bash
|
||||
./nibiru -mi local
|
||||
```
|
||||
|
||||
The migrator records what it has applied so re-running is safe. To apply against staging or production, change the environment:
|
||||
|
||||
```bash
|
||||
APPLICATION_ENV=production ./nibiru -mi production
|
||||
```
|
||||
|
||||
:::caution[Reset commands are destructive]
|
||||
`./nibiru -mi-reset {env}` drops the migrations table and forgets which files were applied. Only use it on a brand-new database you don't mind reseeding.
|
||||
:::
|
||||
|
||||
## First boot
|
||||
|
||||
Point your web server's docroot at the project root (the directory that contains `index.php`). For nginx, use the included `vhost.conf` as a starting point. For Apache, the default `.htaccess` route rewrite into `index.php` is sufficient.
|
||||
|
||||
Browse to `/` and you should see the index template. From there, [the Quick Start](/start/quick-start/) walks you through your first controller and view.
|
||||
144
docs/src/content/docs/en/start/local-testing.md
Normal file
144
docs/src/content/docs/en/start/local-testing.md
Normal file
@@ -0,0 +1,144 @@
|
||||
---
|
||||
title: Run It Locally
|
||||
description: Three ways to spin up the docs site (with the Oracle) on your own machine.
|
||||
---
|
||||
|
||||
The docs site — and the Oracle that lives in the corner — is just an Astro app. You can run it three ways depending on what you have at hand.
|
||||
|
||||
## Option A — Astro dev server, neuronetz.ai backend (fastest)
|
||||
|
||||
The Oracle calls your shared Ollama at `https://api.neuronetz.ai`. No local GPU needed, no API keys to manage.
|
||||
|
||||
```bash
|
||||
cd /home/stephan/PhpstormProjects/Nibiru/docs
|
||||
|
||||
# .env (copy from .env.example, defaults already point at neuronetz.ai)
|
||||
cp .env.example .env
|
||||
|
||||
npm install # one-time
|
||||
npm run dev # http://localhost:4321
|
||||
```
|
||||
|
||||
Open <http://localhost:4321/>. Click the amber Oracle launcher in the bottom-right and ask anything.
|
||||
|
||||
**Pull the embedding model once** on your Ollama host (build-time + runtime):
|
||||
|
||||
```bash
|
||||
curl https://api.neuronetz.ai/api/pull -d '{"name":"nomic-embed-text"}'
|
||||
```
|
||||
|
||||
Without it, the Oracle still works — it just runs in chat-only (no-RAG) mode and answers from the model's parametric knowledge. With it, answers are grounded in this documentation.
|
||||
|
||||
### Build the embedding index
|
||||
|
||||
```bash
|
||||
npm run build:oracle # writes public/oracle-index.json
|
||||
```
|
||||
|
||||
The dev server will pick it up on the next request. Or skip this step entirely — the runtime endpoint handles a missing/empty index gracefully.
|
||||
|
||||
### Inspect the runtime config
|
||||
|
||||
The Oracle's `/api/oracle` endpoint also responds to GET with its current config (no secrets):
|
||||
|
||||
```bash
|
||||
curl http://localhost:4321/api/oracle
|
||||
# {"status":"ok","llm":{"provider":"ollama","ollamaUrl":"https://api.neuronetz.ai",
|
||||
# "model":"qwen2.5-coder:14b"},"embed":{...},"index":{...}}
|
||||
```
|
||||
|
||||
## Option B — Docker compose, locally
|
||||
|
||||
Closer to production. Builds the same image you'd ship.
|
||||
|
||||
```bash
|
||||
cd /home/stephan/PhpstormProjects/Nibiru/docs
|
||||
cp .env.example .env
|
||||
|
||||
docker compose -f docker-compose.yml -f docker-compose.local.yml up --build
|
||||
```
|
||||
|
||||
The `docker-compose.local.yml` override:
|
||||
|
||||
- Publishes `4321:4321` so the app is reachable at <http://localhost:4321/>.
|
||||
- Drops the `VIRTUAL_HOST` / `LETSENCRYPT_HOST` env vars.
|
||||
- Doesn't need an `nginx-proxy` network on your dev box.
|
||||
|
||||
The first build takes 1-2 minutes. Subsequent rebuilds are fast (Docker caches the dep layer).
|
||||
|
||||
## Option C — Fully offline, local Ollama
|
||||
|
||||
For airgapped dev, demos on a plane, or burning your own GPU.
|
||||
|
||||
```bash
|
||||
# 1) Run Ollama on your laptop
|
||||
ollama serve &
|
||||
|
||||
# 2) Pull a chat model (any of these works)
|
||||
ollama pull qwen2.5-coder:14b # 8 GB, recommended
|
||||
# ollama pull qwen2.5-coder:1.5b # 1 GB, faster but less accurate
|
||||
# ollama pull llama3.2:3b # 2 GB, alternative
|
||||
|
||||
# 3) Pull an embedding model
|
||||
ollama pull nomic-embed-text
|
||||
|
||||
# 4) Point the docs at it
|
||||
cd /home/stephan/PhpstormProjects/Nibiru/docs
|
||||
cat > .env <<EOF
|
||||
OLLAMA_BASE_URL=http://127.0.0.1:11434
|
||||
OLLAMA_CHAT_MODEL=qwen2.5-coder:14b
|
||||
OLLAMA_EMBED_MODEL=nomic-embed-text
|
||||
EOF
|
||||
|
||||
npm run build:oracle
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Now `http://localhost:4321/api/oracle` calls your laptop's Ollama. No internet required.
|
||||
|
||||
## Switching to Anthropic / OpenAI (if you want to)
|
||||
|
||||
The Oracle supports paid APIs as a fallback. In `.env`:
|
||||
|
||||
```bash
|
||||
LLM_PROVIDER=anthropic
|
||||
ANTHROPIC_API_KEY=sk-ant-...
|
||||
|
||||
EMBED_PROVIDER=openai
|
||||
OPENAI_API_KEY=sk-...
|
||||
```
|
||||
|
||||
Useful if your Ollama is down, or for comparing answer quality across providers.
|
||||
|
||||
## Smoke-test cheat sheet
|
||||
|
||||
```bash
|
||||
curl http://localhost:4321/ # 301 → /en/
|
||||
curl -I http://localhost:4321/en/ # 200
|
||||
curl http://localhost:4321/api/oracle # GET = config
|
||||
curl -X POST -H 'Content-Type: application/json' \
|
||||
-d '{"messages":[{"role":"user","content":"How do I create a module?"}]}' \
|
||||
http://localhost:4321/api/oracle | jq .answer
|
||||
```
|
||||
|
||||
If the last call returns a real answer that mentions `./nibiru -m`, your stack is wired up correctly.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Oracle returns "the Oracle could not answer".**
|
||||
The Ollama server is unreachable or the chat model isn't pulled. Verify:
|
||||
```bash
|
||||
curl https://api.neuronetz.ai/api/tags | jq '.models[].name'
|
||||
```
|
||||
|
||||
**Oracle answers without citations.**
|
||||
The embedding index is empty. Re-run `npm run build:oracle` after pulling `nomic-embed-text`.
|
||||
|
||||
**Ollama returns 404 model-not-found.**
|
||||
Pull the model, e.g.:
|
||||
```bash
|
||||
curl https://api.neuronetz.ai/api/pull -d '{"name":"qwen2.5-coder:14b"}'
|
||||
```
|
||||
|
||||
**`ECONNREFUSED 127.0.0.1:11434`** in option C.
|
||||
Local Ollama isn't running. Start it with `ollama serve &` (or via your system service).
|
||||
141
docs/src/content/docs/en/start/quick-start.md
Normal file
141
docs/src/content/docs/en/start/quick-start.md
Normal file
@@ -0,0 +1,141 @@
|
||||
---
|
||||
title: Quick Start
|
||||
description: Build a minimal Products page in five minutes — controller, view, navigation entry.
|
||||
---
|
||||
|
||||
By the end of this page you'll have a working `/products` page rendered by Smarty, fed by a controller, listed in the sidebar.
|
||||
|
||||
## 1. Generate a controller
|
||||
|
||||
```bash
|
||||
./nibiru -c products
|
||||
```
|
||||
|
||||
This creates two files:
|
||||
|
||||
```
|
||||
application/controller/productsController.php
|
||||
application/view/templates/products.tpl
|
||||
```
|
||||
|
||||
## 2. Wire the controller
|
||||
|
||||
Open `productsController.php` and replace the body with:
|
||||
|
||||
```php
|
||||
<?php
|
||||
namespace Nibiru;
|
||||
use Nibiru\Adapter\Controller;
|
||||
|
||||
class productsController extends Controller
|
||||
{
|
||||
public function pageAction()
|
||||
{
|
||||
View::assign([
|
||||
'title' => 'Products — Nibiru',
|
||||
'products' => [
|
||||
['id' => 1, 'name' => 'Marduk Gold Plating', 'price' => 99.0],
|
||||
['id' => 2, 'name' => 'Tiamat Hull Sealant', 'price' => 49.0],
|
||||
['id' => 3, 'name' => 'Anu Stardust Polish', 'price' => 19.5],
|
||||
],
|
||||
'css' => Config::getInstance()->getConfig()[View::NIBIRU_SETTINGS]['smarty.css'],
|
||||
'js' => Config::getInstance()->getConfig()[View::NIBIRU_SETTINGS]['smarty.js'],
|
||||
]);
|
||||
}
|
||||
|
||||
public function navigationAction()
|
||||
{
|
||||
JsonNavigation::getInstance()->loadJsonNavigationArray();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Two methods are conventional and **always called** by the [dispatcher](/core/dispatcher/):
|
||||
|
||||
- `navigationAction()` — populates the global navigation menu.
|
||||
- `pageAction()` — renders the page itself.
|
||||
|
||||
Anything matching `?_action=foo` will additionally call a `fooAction()` method between them.
|
||||
|
||||
## 3. Write the view
|
||||
|
||||
Open `application/view/templates/products.tpl`:
|
||||
|
||||
```smarty
|
||||
{include 'shared/header.tpl'}
|
||||
<body>
|
||||
{include file="navigation.tpl"}
|
||||
|
||||
<main class="container">
|
||||
<h1>{$title}</h1>
|
||||
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr><th>#</th><th>Name</th><th>Price</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{foreach $products as $p}
|
||||
<tr>
|
||||
<td>{$p.id}</td>
|
||||
<td><a href="/products/detail/{$p.id}">{$p.name|escape}</a></td>
|
||||
<td>{$p.price|string_format:"%.2f"} €</td>
|
||||
</tr>
|
||||
{/foreach}
|
||||
</tbody>
|
||||
</table>
|
||||
</main>
|
||||
|
||||
{include 'shared/footer.tpl'}
|
||||
</body>
|
||||
```
|
||||
|
||||
Variables passed to `View::assign()` show up as `{$variable}` in Smarty.
|
||||
|
||||
## 4. Add a navigation entry
|
||||
|
||||
Edit `application/settings/config/navigation/main.json` and add:
|
||||
|
||||
```json
|
||||
{
|
||||
"label": "Products",
|
||||
"href": "/products",
|
||||
"icon": "shopping-bag"
|
||||
}
|
||||
```
|
||||
|
||||
The navigation is loaded by `JsonNavigation` and rendered by `navigation.tpl`.
|
||||
|
||||
## 5. Run it
|
||||
|
||||
If you have PHP's built-in server handy:
|
||||
|
||||
```bash
|
||||
APPLICATION_ENV=development php -S localhost:8080 -t .
|
||||
```
|
||||
|
||||
Visit <http://localhost:8080/products/>. You should see your three products with the cosmic theme of your CSS.
|
||||
|
||||
## Add a detail page
|
||||
|
||||
In the same controller:
|
||||
|
||||
```php
|
||||
public function detailAction()
|
||||
{
|
||||
$id = (int) ($_REQUEST['id'] ?? 0);
|
||||
View::assign([
|
||||
'title' => "Product #$id",
|
||||
'id' => $id,
|
||||
]);
|
||||
}
|
||||
```
|
||||
|
||||
`Router` already understands `/products/detail/42` and `/products/marduk-gold-plating/42` (SEO URL form — `id` and `slug` get auto-populated in `$_REQUEST`).
|
||||
|
||||
Create `application/view/templates/products/detail.tpl` with whatever markup you like, and you have a two-page app.
|
||||
|
||||
## Where to next?
|
||||
|
||||
- [Architecture (MMVC)](/core/architecture/) — how all the moving parts fit.
|
||||
- [Modules](/core/modules/) — when to graduate from controllers + traits to a real module.
|
||||
- [Database & Migrations](/core/database/) — wire the page to a real `products` table.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user