Components
Components are TypeScript classes that render HTML strings. They live in src/components/ and extend the base Component<T> class.
The Three Layers
A page in Flint is built from three layers, each with a clear responsibility:
| Layer | Where | What it does |
|---|---|---|
| Markdown | content/*.md |
Provides the content — the text you're reading right now |
| Template | templates/*.html |
Defines the page structure — where navigation, content, and footer go |
| Component | src/components/*.ts |
Renders reusable UI — typed, tested, and data-driven |
Markdown → Content
This file (content/component.md) is plain Markdown with YAML frontmatter. It gets compiled into HTML and injected into the template wherever {{content}} appears.
Template → Structure
This page uses the component-demo template, which places the Gadget component after the Markdown content:
{{head}}
<body>
{{#if navigation}}{{navigation}}{{/if}}
<main>
{{content}} ← your Markdown lands here
{{gadget}} ← the Gadget component renders here
</main>
{{#if label-footer}}{{label-footer}}{{/if}}
{{foot-scripts}}
</body>
Each {{tag}} is resolved by the tag engine at build time. The tag engine is the bridge between templates and components — it maps tag names to component classes and passes data from the page's frontmatter as typed props.
Component → Reusable UI
Components are classes that extend Component<T> where T is a typed props interface. Every component:
- Has a
render()method that returns a pure HTML string - Receives data through typed props — never hardcoded
- Escapes all user-supplied text with
this.escapeHtml()to prevent XSS - Uses Tailwind utility classes for styling — no
<style>tags - Has co-located unit tests in a
*.test.tsfile
Built-in Components
Flint ships with these reusable components, each registered as a {{tag}} in the tag engine:
Site-wide (always available)
| Component | Tag | Data source | Purpose |
|---|---|---|---|
| Navigation | {{navigation}} |
Site hierarchy | Responsive nav bar with hamburger menu |
| LabelFooter | {{label-footer}} |
All site labels | Footer with clickable label cloud |
These read from TemplateContext fields that the build system populates automatically — you don't need to add anything to frontmatter.
Data-driven (from frontmatter)
| Component | Tag | Frontmatter key | Purpose |
|---|---|---|---|
| CtaSection | {{hero}} |
Hero: |
Full-width gradient hero with heading and CTA buttons |
| CtaSection | {{call-to-action}} |
CTA: |
Conversion banner (same component, banner variant) |
| CardGrid | {{feature-grid}} |
Features: |
Responsive grid of icon cards |
| CardGrid | {{showcase-grid}} |
Showcase: |
Linked card grid (same component, cards with href) |
| StatsBar | {{stats-bar}} |
Stats: |
Dark row of headline statistics |
| SkillCards | {{skill-cards}} |
Skills: |
Grid of skill info cards with tag badges |
| Product | {{product}} |
Product fields | Product card or detail view |
Notice that CtaSection and CardGrid each serve two tags. They're generic components with optional props that change their behaviour:
CtaSectionuses avariantprop —'hero'renders an<h1>with decorative blur circles;'banner'renders a simpler<h2>.CardGriditems with anhrefbecome clickable links with hover effects; items with acolorget a coloured icon background.
Standalone (no frontmatter)
| Component | Tag | Purpose |
|---|---|---|
| Gadget | {{gadget}} |
Demo widget (the one below this text!) |
| Cart | {{cart}} |
Shopping cart shell (hydrated client-side) |
How the Tag Engine Connects Everything
The tag engine (src/templates/tag-engine.ts) is a switch statement that maps tag names to components. When the build system encounters {{feature-grid}} in a template, it calls resolveTag('feature-grid', ctx), which:
- Reads the
Features:object fromctx.frontmatter - Passes it as typed props to
CardGrid.render() - Returns the HTML string (or
''if the data is missing)
case 'feature-grid': {
const fg = ctx.frontmatter['Features'] as CardGridProps | undefined;
if (!fg || !fg.items || fg.items.length === 0) return '';
return CardGrid.render(fg);
}
Returning '' when data is missing makes {{#if feature-grid}} conditionals work — the template can guard optional sections so pages without that frontmatter data render cleanly.
Data Flow
content/index.md templates/landing.html src/components/card-grid.ts
┌──────────────┐ ┌─────────────────────┐ ┌─────────────────────┐
│ Features: │ │{{#if feature-grid}} │ │ CardGrid.render() │
│ heading:… │──────> │ {{feature-grid}} │────> │ <section>… │
│ items: │ YAML │ {{/if}} │ tag │ <div class=grid> │
│ - icon… │ └─────────────────────┘ engine│ <div>card</div> |
│ - icon… │ │ </div> │
└──────────────┘ └─────────────────────┘
Content authors control what data appears. Components control how it looks. Templates control where it goes. No layer reaches into another.
Example: The Gadget Component
The Gadget below is a simple demo component. It shows how even a component with client-side interactivity is still rendered server-side as a static HTML string.
Integration Steps
1. The component (src/components/gadget.ts) extends Component<GadgetProps> and renders a coloured box with a randomize button. The client-side JavaScript is embedded in the HTML output as an inline <script>:
export class Gadget extends Component<GadgetProps> {
render(): string {
return `<div class="gadget-wrapper">
<div id="gadget-box" style="background-color: #6366f1;">
<p id="gadget-text">${this.escapeHtml(this.props.initialText ?? 'Click the button!')}</p>
<button onclick="randomizeGadget()">🎲 Randomize</button>
</div>
<script>function randomizeGadget() { /* ... */ }</script>
</div>`;
}
}
2. The tag engine registers it with no frontmatter dependency — Gadget has no data props, so it renders the same on every page:
case 'gadget':
return Gadget.render({});
3. The template (templates/component-demo.html) places it after the content:
<main>
{{content}}
<section class="mt-10">{{gadget}}</section>
</main>
4. This content file (content/component.md) selects the template via Template: component-demo. The Markdown body becomes {{content}}, and the Gadget renders below it.
Try it! Click the Randomize button below to see the component in action.
Click the button!