kartonjs
v2.0.5
Published
KartonJS is a lightweight, class-based Web Component framework with built-in reactive state, effects, computed values, and a slot-friendly templating system using lit-html.
Downloads
24
Maintainers
Readme
KartonJS 📦
KartonJS is a lightweight, class-based Web Component framework with built-in reactive state, effects, computed values, and a slot-friendly templating system using lit-html.
- "KartonJS is for web devs who want full control and zero fluff."
- “Like Lit, but smaller.”
- “Like React, but closer to the platform.”
- “If you love Solid's reactivity but hate JSX, you’ll love KartonJS.”
- “Slots without 'shadowRoot'”
- “Ideal for building modern web apps without boilerplate.”
For more info read the website: KartonJS Website 🌐 (For 🧩 Components click the toggle) KartonJS Gitlab 🦊 KartonJS NPM 🐝
📦 Exports
- kartonjs as default { KartonElement, html, isDev, memoryStorage }.
KartonElementthe element to extend for your webcomponents.htmlthe lit-html literal function to generate templates to render.unsafeHTMLrenders rawHTML into templates to render.isDeva boolean whether you are on a local system or live.memoryStorageis a simple variant of localStorage that forgets everthing when the page reloads. - kartonjs/components/card a card with slots in lightDOM.
- kartonjs/components/input-area an input textarea with the comfort of having a value property.
- kartonjs/components/status-bar a status bar with custom message.
- kartonjs/components/activity-indicator an indicator that is with a color and message template.
- kartonjs/components/toast toasts fully configurable with slots.
- kartonjs/components/router a router that gives you variables and wildcards, configures with JSON.
- kartonjs/components/switch a switch (show/hide) with equal router functionality, configures also with JSON.
- kartonjs/components/devtools devtools for devs projects states and effects of current KartonElements.
- kartonjs/components/piece an element that imports .html files inside of it.
- kartonjs/components/requester a JSON request then uses the {{ val.ues }} in specified template.
- kartonjs/components/trigger triggers its template on triggering to a target if speciefied.
- kartonjs/components/toggle toggles two templates to a target if speciefied.
- kartonjs/components/form full REST supported form.
- kartonjs/components/markdown generates HTML from Markdown.
- kartonjs/components/docs auto docs from files with the export
docs - kartonjs/components/grid a responsive grid layout using CSS Grid and template slots.
- kartonjs/components/tabs a tabs layout exposing template slots.
- kartonjs/components/accordion an accordion layout exposing template slots.
- kartonjs/components/tape ejs template layout using data attribute/property template slots.
- kartonjs/components/ink handlebars template layout using data attribute/property template slots.
🤔 Why KartonJS?
- ✅ Native Web Components — no JSX, no transpilation needed
- ✅ Reactive like Solid — signals, computed values, effects
- ✅ Use slots in light DOM — real HTML composition
- ✅ Debug-friendly dev tools
- ✅ Built-in state reflection, storage sync, and attribute mapping
🚀 Getting Started
- Install
Include via CDN:
<script type="module">
import { KartonElement, html } 'https://cdn.jsdelivr.net/npm/kartonjs/KartonElement.js';
</script>Or use locally in a module project:
npm install kartonjs
npm install vite --save-devThere is also a npx command to scaffold a kartonjs project at once:
npx create-karton-app my-app- Create Your First Component
./src/components/karton-world.js:
import { KartonElement, html } from 'kartonjs';
class KartonWorld extends KartonElement {
init() {
[this.getName, this.setName] = this.State('name', 'World');
}
template() {
return html`<p>Karton, ${this.getName()}!</p>`;
}
}
customElements.define('karton-world', KartonWorld);- Create or edit your index.html file
./index.html:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Karton World</title>
<link rel="icon" type="image/svg+xml" href="https://cdn.jsdelivr.net/npm/kartoncss/karton-element.svg" />
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/kartoncss/karton.css" type="text/css">
<script type="module" src="./src/components/karton-world.js"></script>
</head>
<body>
<karton-world></karton-world>
</body>
</html>- Run in your project folder and open the browser http://localhost:5173/
npx vite- To fancy it up, add this script to the body of your index.html
<script type="module">
document.querySelector('karton-world').setName("Everyone");
</script>- Publish
If you like you can build and publish your project with:
npx vite buildThis will produce a ./dist/ that you can upload, deploy to Git* pages or share via a free service like Netlify.
❓ FAQ
Q: I got any render error ..
A: KartonJS uses the lit-html literal to parse the template function result into HTML, please read the lit-html docs
Q: Do I need to distribute file README.md on changes?
A: Only if the changes you added are of influence for the API, all other changes are fine without updating the README.md
Q: Why is KartonElement.js not minified by default like other JS projects?
A: The project is small enough to not have any hinder of not being minified.
If you still like it to be minified, configure vite, or rollup to do this for you.
📚 KartonElement API Reference
- constructor()
- slot(name, default)
- State(key, value)
- BusState(key, value)
- Effect(fn, deps)
- render() / template()
- Computed(fn, key)
- BoolAttrEffect(attr, getter)
- SyncAttrEffect(attr, getter)
- attributeChangedCallback()
- reflectAttribute()
- connectedCallback()
- disconnectedCallback()
- safeJsonParse() / stringifyJSON()
constructor()
Initializes the element, sets up private state containers, and attaches light DOM.
constructor() {
super();
// Initializes internal private state and effect arrays
} slot(name = undefined, default = undefined)
🔌 Reads the template slot. This is great for developers who like to still use slots even though KartonElement uses lightDOM by default. The this.slot(name, def) method allows your KartonElement components to safely fetch a named < template slot="..." > from light DOM, with an optional default fallback. Use it when you want to allow external HTML content (like an API result or custom template) to be injected into your component, while also providing a fallback if none is defined.
🧪 Syntax
const tpl = this.slot(name = undefined, def = undefined);
Parameter Type Description name string Slot name (matches template[slot="name"]) def string Optional fallback HTML string, used if no matching slot found
✅ Returns
A HTMLTemplateElement if found (either from light DOM or generated from fallback). Returns null if neither a slot nor fallback is available.
🧠 How to use
render() {
const tpl = this.slot('info', `<p>No info provided</p>`);
const nodes = [...tpl.content.cloneNode(true).childNodes];
return html`${nodes}`;
}Or
render() {
const tpl = this.slot('info', `<p>No info provided</p>`);
const str = tpl.innerHTML; // make any changes as you like
const hole = unsafeHTML(str);
return html`${hole instanceof Array ? hole : [hole]}`; // this returns a html`...` for the DOM both object or array
}📥 Examples
slot(name = undefined, def = undefined) {
var userSlot;
if (typeof name !== 'undefined') {
userSlot = this.querySelector(`template[slot='${name}']`);
} else {
userSlot = this.querySelector('template');
}
if (userSlot) return userSlot;
if (typeof def !== 'undefined') {
const defTemplate = document.createElement('template');
defTemplate.innerHTML = def;
return defTemplate;
}
console.warn(`The template slot '${name}' was not found and there was no default for it.`);
return null;
}This function is used to make an element slottable within the lightDOM.
An example of usage would be the router:
<karton-router>
<template slot="routes" type="application/json">
[
{ "path": "/", "component": "karton-home", "title": "Home - Karton App" },
{ "path": "counter/:id", "component": "karton-counter", "title": "Counter" },
{ "path": "settings/:section", "component": "karton-settings", "title": "Settings" },
{ "path": "about/*", "component": "karton-about", "title": "About Us" },
{ "path": "*", "component": "karton-notfound", "title": "Not Found" }
]
</template>
</karton-router>read more while inspecting: router.
Or in this example card:
<karton-card>
<template slot="header">🌟 My Header</template>
<template slot="main">
<div>
<p>Welcome to the beginning body!</p>
<p>Welcome to the main body!</p>
<p>Welcome to the end of the body!</p>
</div>
</template>
<template slot="footer">📎 Footer content</template>
</karton-card>See the example source of the card.
Or in this example requester:
<karton-requester src="https://official-joke-api.appspot.com/jokes/random">
<template slot="main" type="text/html">
<h5>Joke:</h5>
<div>
{{ setup }}
</div>
<div>
{{ punchline }}
</div>
</template>
</karton-requester>
<br>
<karton-requester src="https://jsonplaceholder.typicode.com/users">
<template slot="main" type="text/html">
<h5>Users:</h5>
<ul>
{{#each .}}
<li>
<strong>{{ name }}</strong><br>
Email: {{ email }}<br>
City: {{ address.city }}
</li>
{{/each}}
</ul>
</template>
</karton-requester>See the example source of the requester.
If < template slot="info" > is missing, KartonJS will fall back to your default string. ⚠️ Dev Warning
If no slot and no fallback are provided, a warning is logged to the console:
The template slot 'info' was not found and there was no default for it.
🧠 Pro tip
Use with unsafeHTML() or html`` to safely interpolate the slot content into your reactive component layout.
return html<section class="content">${unsafeHTML(this.slot('main', '<p>Hello</p>').innerHTML)}</section>;
Let me know if you'd like the same structure formatted as JSDoc for inline code comments, or a Markdown version styled for your docs site.
safeJsonParse(json)
Safely parses JSON strings, returns RAW if failed.
safeJsonParse(json) {
try {
return JSON.parse(json);
} catch (e) {
return json;
}
} stringifyJSON(json, space = 0)
Special stringify to JSON that also accepts Elements.
stringifyJSON(json, space = 0) {
function replacer(key, value) {
if (value instanceof Element) {
return `<${value.tagName.toLowerCase()} ...>`;
}
return value;
}
try {
return JSON.stringify(info.state, replacer, space);
} catch (e) {
console.log("Error during stringify JSON:", e);
}
}getAttrJSON(a)
Get an attribute value safely parsed, if it is a number or a boolean it is all parsed. If it is a normal string it will just return the string as is.
getAttrJSON(a){
return this.safeJsonParse(this.getAttribute(a));
}getAttrJSON(a)
Set an attribute value, if it is a number, a boolean or a string, it will all be stringified.
setAttrJSON(a, v){
return this.setAttribute(a, this.stringifyJSON(v));
}connectedCallback()
Lifecycle hook when element is inserted into DOM. Initializes ID, adds devtools integration if in dev mode, calls init(), then calls render() if a template function exists.
connectedCallback() {
// Define i
this.i = this.id || "-";
// Define Development tools integration
if (this.debug) {
// Dev Instances Registser
if (!window.__Karton__) {
window.__Karton__ = { instances: new Set() };
}
window.__Karton__.instances.add(this);
// Dev Inspect
window.__Karton__.inspect = (tagOrId) => {
const all = [...window.__Karton__.instances];
if (!tagOrId) return all;
return all.find(c => c.tagName.toLowerCase() === tagOrId || c.id === tagOrId);
};
}
// inti - user-defined initialization
this.init();
// render - can be user-defined but has standard
this.render();
}disconnectedCallback()
Cleanup lifecycle hook: unsubscribes all listeners, cleans up effects, removes from devtools.
disconnectedCallback() {
for (const unsub of this.#unsubscribers) unsub();
this.#unsubscribers = [];
window.__Karton__?.instances.delete(this);
for (const { label, cleanup } of this.#effects) {
this.debug && console.log('[KartonJS]', `<${this.tagName.toLowerCase()} id=${this.i}> Cleaning up effect: ${label}`);
if (typeof cleanup === 'function') cleanup();
}
this.#effects = [];
}init()
User-overridable method for initialization logic.
This is the function that is used to do your preparation. Setting variables, reading template slots, creating State or BusState and all other logic that needs to be done before rendering the template of your element.
init() {
// Override in subclasses
} render()
User-overridable method for render logic.
This function render function uses the render() function from lit-html to render your template into HTML elements and publishes it within the component.
By default KartonElement is using lightDOM so the global CSS is applied. If you like direct custom styles, please use the style attribute style=${styleVar}.
render() {
if (typeof this.template === 'function') {
try {
HTMLrender(this, this.template.bind(this));
} catch (e) {
console.error('[KartonJS] Render Error:', e);
// Show the error message in the component itself
if ( e.message === "node is null" ) {
e.message = `<p>${e.message}</p><p>Please notice that KartonElement is using the render function from 'lit-html' and for example: class=\"button \${varname}\" is not allowed, only class=\${varname}.\nTo know for sure that your template renders, please read: lit-html"</p>`;
}
this.innerHTML = `
<div style="
padding: 1em;
border: 2px solid red;
background: #fee;
color: #900;
font-family: monospace;
white-space: pre-wrap;
">
<strong>Rendering Error in <${this.tagName.toLowerCase()} id="${this.id}">:</strong>
<br>${e.message}
</div>
`;
}
}
}template()
Set this according to the content of your component, probably in combination with the lit-html template literal html<div style=${styleVaribale}>${contentVariable}</div>.
The render function in connectedCallback will pickup the output of this function and render it.
By default KartonElement is using lightDOM so the global CSS is applied. If you like direct custom styles, please use the style attribute style=${customStyle}.
template() {
const customStyle = `
background-color: blue;
`;
return html`
<div class="info" style=${customStyle}>
<p>Some Information</p>
</div>
`;
}A nice takeaway is that change of State, like text in the example beneeth, automatically triggers a re-render of the template.
This way your content always stays in sync.
init() {
// Reactive state
[this.getText, this.setText] = this.State('text', '');
...
}
template() {
return html`
...
${this.getText()}
`;
}State(key, initialValue)
Creates a reactive state signal for key. Initialized from attribute, or initial value. Reflects changes to attribute when it is specified as a observedAttribute both ways. Returns [getter, setter].
State(key, initialValue) {
if (!(key in this.#state)) {
let value;
if (this.hasAttribute(key)) {
value = this.safeJsonParse(this.getAttribute(key));
this.debug && console.log('[KartonJS]', `<${this.tagName.toLowerCase()} id=${this.i}> State '${key}' initialized by getAttribute:`, value);
} else {
value = initialValue;
this.debug && console.log('[KartonJS]', `<${this.tagName.toLowerCase()} id=${this.i}> State '${key}' initialized by initialValue:`, value);
}
const s = signal(value);
s.__label = key;
this.#state[key] = s;
const cleanup = effect(() => {
this.debug && console.log('[KartonJS]', `<${this.tagName.toLowerCase()} id=${this.i}> State changed: ${key} =`, s.value);
this.reflectAttribute(key, s.value) && this.debug && console.log('[KartonJS]', `attribute reflected: ${key} =`, s.value);
});
this.#effects.push({ label: `state:${key}`, cleanup });
}
const s = this.#state[key];
return [() => s.value, v => s.value = v];
}An example to use this:
<karton-status-bar
text="Connected to Wi-Fi"
color="#4caf50"
height="30px"
></karton-status-bar> init() {
// Reactive state
[this.getText, this.setText] = this.State('text', '');
...
}
template() {
return html`
...
${this.getText()}
`;
}The text in the template automatically updates when you call the function like this: document.querySelector('karton-status-bar').setText("Changed TEXT!");
Or if you set observedAttributes in the top of your component:
static get observedAttributes() {
return ['text'];
}You can even do this: document.querySelector('karton-status-bar').setAttribute('text', 'Other changed TEXT!');
And the text in you component will changed, since the observedAttribute and the State are automatically connected.
BusState(key, initialValue, storage = memoryStorage)
Creates a globally shared reactive state with pub/sub synchronization. Initialized from attribute, storage or initial value. Reflects changes to attribute when it is specified as a observedAttribute both ways. Returns [getter, setter].
BusState(key, initialValue, storage = this.Storage) {
let s;
const alreadyExists = key in this.#state;
if (!alreadyExists) {
let value;
if (this.hasAttribute(key)) {
value = this.getAttrJSON(key);
this.debug && console.log('[KartonJS]', `<${this.i}> BusState '${key}' initialized by getAttribute:`, value);
} else if (storage.getItem(key) !== null) {
value = this.safeJsonParse(storage.getItem(key));
this.debug && console.log('[KartonJS]', `<${this.i}> BusState '${key}' initialized by Storage:`, value);
} else {
value = initialValue;
this.debug && console.log('[KartonJS]', `<${this.i}> BusState '${key}' initialized by initialValue:`, value);
}
s = signal(value);
s.__label = key;
this.#state[key] = s;
} else {
s = this.#state[key];
}
const unsubscribe = stateBus.subscribe(key, newVal => {
this.debug && console.log('[KartonJS]', `<${this.i}> SUB: ${key} =`, newVal);
if (s.value !== newVal) s.value = newVal;
});
this.#unsubscribers.push(unsubscribe);
const hasEffect = this.#effects.some(e => e.label === `bus:${key}`);
if (!hasEffect) {
const cleanup = effect(() => {
this.debug && console.log('[KartonJS]', `<${this.i}> BusState changed: ${key} =`, s.value);
stateBus.publish(key, s.value) && this.debug && console.log('[KartonJS]', `<${this.i}> PUB: ${key} =`, s.value);
this.reflectAttribute(key, s.value) && this.debug && console.log('[KartonJS]', `attribute reflected: ${key} =`, s.value);
storage.setItem(key, this.stringifyJSON(s.value)); this.debug && console.log('[KartonJS]', `stored in Storage: ${key} =`, s.value, this.stringifyJSON(s.value));
});
this.#effects.push({ label: `bus:${key}`, cleanup });
}
return [() => s.value, v => s.value = v];
}BusState is the an autmatically pub/sub-scribing functionality build in the KartonElement.
Here the color of the theme is synchronized to the main <karton-app> from <karton-settings-color>:
customElements.define('karton-settings-color', class extends KartonElement {
init() {
// color theme State
[this.colorTheme, this.setColorTheme] = this.BusState('colorTheme', null, localStorage);
requestAnimationFrame(() => {
document.querySelector('#colorThemeSelect').value = this.colorTheme();
document.querySelector('#colorThemeSelect').setCustomValidity("Invalid field.");
});
//this.setColorTheme(document.querySelector('#colorThemeSelect').value)
}
template() {
return html`
<div>
<karton-card>
<template slot="header">Theme Color</template>
<template slot="main">
<div>
<p>
<label>color theme</label>
<select id="colorThemeSelect" onchange="document.querySelector('karton-settings-color').setColorTheme(this.value)">
<option value="light">light</option>
<option value="dark">dark</option>
</select>
</p>
</div>
</template>
<template slot="footer">you choose</template>
</karton-card>
</div>
`;
}
});And in <karton-app>:
...
// color theme State
[this.colorTheme, this.setColorTheme] = this.BusState('colorTheme', null, localStorage);
this.Effect(() => {
document.body.className = this.colorTheme();
}, [this.colorTheme], 'color-theme');
}
...
reflectAttribute(key, val)
Reflects a state value to an observed attribute (if it exists).
reflectAttribute(key, val) {
let oAttr = this.constructor.observedAttributes || [];
if (oAttr.includes(key) && val !== null) {
return this.setAttribute(key, val);
}
return;
} This function takes care of automatic updating of attributes when they are changed (for example because of stateChange).
attributeChangedCallback(name, oldValue, newValue)
Called on attribute changes. Coerces and updates internal state and publishes changes on the bus.
attributeChangedCallback(name, oldValue, newValue) {
if (oldValue === newValue) return;
this.debug && console.log('[KartonJS]', `<${this.tagName.toLowerCase()} id=${this.i}> '${name}' attributeChanged:`, oldValue, '→', newValue);
const coerced = this.safeJsonParse(newValue);
if (name in this.#state && this.#state[name].value !== coerced) {
this.#state[name].value = coerced;
stateBus.publish(name, coerced);
}
}Here change of attritbute is pushed to state and busstate.
Effect(fn, deps)
Runs a reactive effect, optionally tracking dependencies and supports cleanup.
Effect(fn, deps) {
if (!Array.isArray(deps)) {
throw new Error("Effect() expects second argument to be an [], or an array of signal getters.");
}
const label = 'effect' + Math.round(Math.random() * 99999999);
const run = () => {
this.debug && console.log('[KartonJS]', `<${this.tagName.toLowerCase()} id=${this.i}> Effect Triggered: ${label}`);
const result = fn();
return typeof result === 'function' ? result : null;
};
const cleanup = deps
? effect(() => {
deps.map(d => d()); // for tracking
return run();
})
: effect(run);
this.#effects.push({ label, cleanup, deps });
}An example of using an Effect:
// first specify the State
[this.check, this.setCheck] = this.State(`${this.id}:check`, false);
// second specify the Effect
this.Effect(() => {
const iNum = ++this.iNum;
isDev && console.log('[KartonJS]', `[${this.id}] ⚙️ Effect starting for interval #${iNum}`);
const interval = setInterval(() =>
isDev && console.log('[KartonJS]', `[${this.id}] ⏱️ interval #${iNum} running`), 2000);
return () => {
isDev && console.log('[KartonJS]', `[${this.id}] 🧹 Cleanup for interval #${iNum}`);
clearInterval(interval);
};
}, [this.check], 'check-interval');You specify a function that needs to be executed whenever a State is updated, in this case [this.check] and you finish with a label check-interval.
The return of the the function is the cleanup, which is needed in this case because of the setInterval().
Computed(computeFn, key)
Creates a cached computed signal based on computeFn. Returns a getter function.
Computed(computeFn, key) {
if (typeof computeFn !== 'function') {
throw new Error(`Computed expects a function, but got: ${typeof computeFn}`);
}
if (typeof key !== 'string' || !key.trim()) {
throw new Error(`Computed requires a string key as 2nd argument, e.g. Computed(fn, 'myName')`);
}
// Only initialize once under that key
if (!(key in this.#state)) {
const c = computed(computeFn);
c.__label = key;
this.#state[key] = c;
// Log updates under the same key
const cleanup = effect(() => {
const val = c.value;
this.debug && console.log(`[KartonJS] <${this.tagName.toLowerCase()} id=${this.i}> computed:${key} updated:`, val);
});
this.#effects.push({ label: `computed:${key}`, cleanup });
}
// Return a getter for the computed signal
return () => this.#state[key].value;
}An example to use it:
// Computed doubled value
this.getDouble = this.Computed(() => {
return this.count() * this.multiply()
}, 'double');This is a little bit like a getter on math steriods, whenever you call this you get the updated value of this calculation.
BoolAttrEffect(attr, getter)
Adds or removes a boolean attribute reactively based on a getter function.
BoolAttrEffect(attr, getter) {
this.Effect(() => {
const val = getter();
if (val === false || val === null || val === undefined) {
this.removeAttribute(attr);
this.debug && console.log('[KartonJS]', `<${this.tagName.toLowerCase()} id=${this.i}> attribute '${attr}' removed`);
} else {
this.setAttribute(attr, '');
this.debug && console.log('[KartonJS]', `<${this.tagName.toLowerCase()} id=${this.i}> attribute '${attr}' added`);
}
}, [getter]);
}Adds or Removes attribute according to the given State getter.
SyncAttrEffect(attrName, getter)
Keeps a string attribute in sync with a reactive getter function.
SyncAttrEffect(attrName, getter) {
this.Effect(() => {
this.setAttribute(attrName, getter());
}, [() => getter()]);
}This is handy to update other attributes then the one equally labeled/named to the state.
Auto updates attribute accourding to given State getter.
Components
| Component | Description | CDN Link |
| --------------------------------- | ------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------- |
| <karton-card> | A layout card with header, main, and footer slots in light DOM | karton-card.js |
| <karton-input-area> | A textarea input with value binding convenience | karton-input-area.js |
| <karton-status-bar> | A status bar with configurable text, color, and height | karton-status-bar.js |
| <karton-activity-indicator> | A visual indicator for async actions with custom message and color | activity-indicator.js |
| <karton-toast> | Toast message system with configurable slots | karton-toast.js |
| <karton-router> | Declarative client-side router using JSON-configured routes | karton-router.js |
| <karton-switch> | Conditional content switcher with router-style path matching | karton-switch.js |
| <karton-devtools> | Developer UI to inspect active components, state, and effects | devtools |
| <karton-piece> | Loads and injects .html file fragments at runtime | piece |
| <karton-requester> | Fetches JSON and injects it using Mustache-style templating | requester |
| <karton-trigger> | Triggers template content into a target when fired | trigger |
| <karton-toggle> | Switches between two templates into a target element | toggle |
| <karton-form> | REST-compatible form handler with full submission support | form |
| <karton-markdown> | Converts Markdown to HTML using a <template slot> | markdown |
| <karton-docs> | Generates documentation UI from docs export or markdown files | docs |
Roadmap
- 🧪 Simple unit testing utilities or helpers for components
- 📚 A beautifully written, focused documentation site - The README is great, but docs.kartonjs.org with examples, guides, recipes will seal the deal.
- 🎯 Examples gallery extension - Chatbot UI, Form Wizard, Blog Card, TodoMVC, Admin Panel
- 🔌 Plugin support or extension ideas (router, form binding, markdown rendering)
License
MIT © Biensure Rodezee
