From Eleventy

Eleventy is a static site generator centered on template languages and Markdown rendering; Bascik is a build tool for HTML components. Neither adds a client-side runtime by default, so both produce vanilla HTML (the Eleventy base blog itself ships one small module script for heading anchors). The main conceptual shift is that Bascik uses standard HTML component files resolved by tag name, rather than template languages like Nunjucks, Liquid, or Handlebars. Loops, conditionals, and filters move into small TypeScript helpers that build scripts call.

Mental Model Comparison

ConceptEleventyBascik
Component partial_includes/nav.njk ({% include %})src/components/site-nav/site-nav.html (<site-nav>)
Layout inheritance{% extends "base.njk" %}<site-layout> with data-bascik-slot
Front matter / Global dataFront matter + _data/ directory<script data-bascik-build> with Node.js fs
Scoped stylesGlobal CSS or build toolsPaired .css files (auto-scoped and deduplicated)
Client interactivityStatic JS files in output<script> tags in components (auto-scoped instances)

A Low-Risk First Step

Migrate a simple partial (like a header or footer) into a Bascik component:

  1. Create a project with npm create bascik@latest my-site, or add Bascik to an existing folder with npm install @bascik/bascik.
  2. Create src/components/site-footer/site-footer.html.
  3. Add your footer HTML markup inside it. Replace template values such as {{ eleventy.generator }} with fixed text.
  4. Use <site-footer /> inside src/pages/index.html.
  5. Run npm run dev to inspect the generated HTML.

Template Languages → HTML Component Files

Eleventy supports many template languages. In Bascik, all components are plain .html files. There is no template syntax, values are either hardcoded, injected as data-bascik-prop-* text props, passed as slot content, or generated by a <script data-bascik-build> block.

Those four mechanisms cover everything a blog template does, but expect the logic to move. Every {% for %}, {% if %}, filter, and loop.last check becomes a TypeScript function in src/lib/ that returns HTML, and a build script prints its result. In the base blog port, those helpers are most of the code.

text
Before (Eleventy)           After (Bascik)
src/                        src/components/
  _includes/                  site-layout/
  blog/                     src/pages/
    my-post.md                index.html
  index.njk                   about/index.html
  about.njk                   blog/index.html
                              blog/[slug]/index.html
                            src/lib/
                              posts.ts   ← reads content/blog
                              render.ts  ← loops and conditionals
                            content/blog/
                              my-post.md

Markdown posts stay in a content/ folder outside src/pages/. One dynamic route template turns them into pages.

{% include %} → Bascik Component Tags

An Eleventy {% include 'nav.njk' %} that renders fixed markup becomes a self-contained Bascik component tag. The file name (hyphenated, without extension) is the tag name. An include that reads variables set by the calling template, such as the base blog's postslist.njk with postslist and postslistCounter, becomes a helper function instead: renderPostList(posts, counter) returns the list as HTML

{% include %} → Bascik Component Tags

An Eleventy {% include 'nav.njk' %} becomes a self-contained Bascik component tag. The file name (hyphenated, without extension) is the tag name.

njk
{# src/_includes/nav.njk (Eleventy - before) #}
<nav class="nav">
  <a href="/">Home</a>
  <a href="/about">About</a>
  <a href="/blog">Blog</a>
</nav>

{# Used in a page with: #}
{% include "nav.njk" %}
html
<!-- src/components/site-nav/site-nav.html (Bascik - after) -->
<nav class="nav">
  <a href="/">Home</a>
  <a href="/about">About</a>
  <a href="/blog">Blog</a>
</nav>

<!-- Used in a page with: -->
<site-nav></site-nav>

{% extends %} / Template Inheritance → Slot-based Layout Components

Eleventy's {% extends 'base.njk' %} and {% block content %} pattern maps to a Bascik layout component that uses data-bascik-slot for the page content area and named slots for additional regions like the page title.

njk
{# src/_includes/base.njk (Eleventy - before) #}
<!DOCTYPE html>
<html lang="en">
<head>
  <title>{{ title }} - Acme</title>
  <link rel="stylesheet" href="/css/styles.css" />
</head>
<body>
  {% include "nav.njk" %}
  <main>{% block content %}{% endblock %}</main>
  {% include "footer.njk" %}
</body>
</html>
njk
{# src/about.njk (Eleventy - before) #}
---
title: About
---
{% extends "base.njk" %}
{% block content %}
<h1>About</h1>
<p>We build things.</p>
{% endblock %}

In Bascik, the layout component wraps only the repeated inner structure. The <html>, <head>, and <body> live in the page file; a component that tries to own them fails the build, because the page then has no body of its own. To avoid repeating the head on every page, put the shared tags in a head component that takes the title and description as props, such as <site-head data-bascik-prop-title="About">.

html
<!-- src/components/site-layout/site-layout.html (Bascik - after) -->
<site-nav></site-nav>
<main class="content">
  <div data-bascik-slot></div>
</main>
<site-footer />
html
<!-- src/pages/about.html (Bascik - after) -->
<!DOCTYPE html>
<html lang="en">
<head>
  <title>About - Acme</title>
  <link rel="stylesheet" href="/css/styles.css" />
</head>
<body>
  <site-layout>
    <h1>About</h1>
    <p>We build things.</p>
  </site-layout>
</body>
</html>

Front Matter Data → Hardcoded HTML or Build Scripts

Eleventy front matter (YAML between --- delimiters) injects variables into the template. Bascik has no front matter. Values that differ per page are either written directly into the HTML or supplied via data-bascik-prop-* props. Use a <script data-bascik-build> block when you want to read values from an external data file at build time.

njk
{# src/about.njk (Eleventy - before) #}
---
title: About
description: Learn about Acme Corp.
---
<h1>{{ title }}</h1>
<meta name="description" content="{{ description }}" />
html
<!-- src/pages/about.html (Bascik - after) -->
<!DOCTYPE html>
<html lang="en">
<head>
  <title>About - Acme</title>
  <meta name="description" content="Learn about Acme Corp." />
  <link rel="stylesheet" href="/css/styles.css" />
</head>
<body>
  <site-layout>
    <h1>About</h1>
  </site-layout>
</body>
</html>

For many pages sharing a data-driven title pattern, extract the title into a data-bascik-prop-title prop on the layout component, or generate the pages from a dynamic route.

Markdown posts keep their front matter. Your helper parses it with gray-matter (npm install gray-matter) and should validate it, for example with a zod schema, so a missing date fails the build instead of producing a broken page.

Drafts

Eleventy's base blog drops draft: true posts from builds and keeps them in --serve. In Bascik, read process.env.BASCIK_BUILD in the helper that loads posts: it is "1" for bascik --build and "0" for the dev server.

ts
// src/lib/posts.ts (excerpt)
const includeDrafts = process.env.BASCIK_BUILD !== '1';
const visible = posts.filter((post) => includeDrafts || post.data.draft !== true);

Collections → <script data-bascik-build>

Eleventy's Collections API reads a directory of Markdown files and makes them available as a sorted array in templates. In Bascik, use a <script data-bascik-build> block that reads the same directory and returns a list of HTML links directly into the page. Sort by the parsed date, as Eleventy does, not by file name: file names such as firstpost, secondpost, thirdpost sort alphabetically into the wrong order.

njk
{# src/blog/index.njk (Eleventy - before) #}
<ul>
  {%- for post in collections.post | reverse -%}
  <li>
    <a href="{{ post.url }}">{{ post.data.title }}</a>
    <time>{{ post.date | dateFilter }}</time>
  </li>
  {%- endfor -%}
</ul>
html
<!-- src/pages/blog/index.html (Bascik - after) -->
<ul>
  <script data-bascik-build>
    import { readdir, readFile } from 'node:fs/promises';
    import matter from 'gray-matter';

    const escape = (s) => String(s).replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('"', '&quot;');

    export default async function () {
      const files = (await readdir('./content/blog')).filter(f => f.endsWith('.md'));

      const posts = await Promise.all(files.map(async file => {
        const { data } = matter(await readFile(`./content/blog/${file}`, 'utf8'));
        return { slug: file.replace(/\.md$/, ''), title: data.title, date: new Date(data.date) };
      }));
      posts.sort((a, b) => b.date - a.date); // newest first, like `| reverse`

      return posts.map(post => `<li>
        <a href="/blog/${post.slug}/">${escape(post.title)}</a>
        <time datetime="${post.date.toISOString().slice(0, 10)}">${post.date.toLocaleDateString('en-US', { month: 'long', year: 'numeric', timeZone: 'UTC' })}</time>
      </li>`).join('\n');
    }
  </script>
</ul>

In a real site, move the reading and sorting into src/lib/posts.ts so the home page, the archive, tag pages, and the feed share one list. Scripts that read content/ need scripts.cache.exclude and pipeline.watchPaths, because Bascik cannot see that folder as an import; see Invalidation Limits & Cache Exclusions.

Build scripts run first: A build script's output can itself contain Bascik component tags. They are resolved in the next pass, so you can output <post-card> tags from a build script and Bascik will expand them.

Tag Pages and Post Pages

Eleventy's pagination over collections becomes a dynamic route. src/pages/blog/[slug]/index.html produces /blog/<slug>/ for each post, and src/pages/tags/[tag]/index.html produces /tags/<tag>/. A route parameter cannot contain /, so a nested URL such as /blog/2026/post/ needs its own template, src/pages/blog/[year]/[slug]/index.html.

Passthrough File Copy → Automatic

In Eleventy, non-template files (CSS, images, fonts) must be explicitly added to the passthrough copy list in .eleventy.js. In Bascik, all non-.html files in src/pages/ are copied to dist/ automatically at build time with no configuration needed. Source files are never copied: dotfiles and .html, .md, .ts, .mjs, .cjs, .mts, .cts, .map, and test files are on a built-in deny-list (see directory). Images that live beside Markdown in content/ are outside src/pages/, so copy them with a pipeline.exec script that writes into the output directory.

text
src/pages/
  index.html     ← transpiled
  about.html     ← transpiled
  css/
    styles.css   ← copied as-is
  img/
    hero.jpg     ← copied as-is
  fonts/
    inter.woff2  ← copied as-is

eleventy.config.js Plugins → Your Own Build Steps

Bascik replaces only one plugin category natively: CSS and JS scoping. Each of the base blog's other plugins becomes a small build step you write with a general-purpose library:

Eleventy plugin or templateBascik replacement
sitemap.xml.njkBuilt in. bascik --build writes sitemap.xml and robots.txt when a site URL is set. See Sitemap & robots.txt
@11ty/eleventy-plugin-rssA pipeline.exec script with phase: 'post' that writes the feed to the output directory, using a library such as feed
@11ty/eleventy-plugin-syntaxhighlightHighlight code in your Markdown render step with prismjs
IdAttributePlugin, <heading-anchors>Add heading ids and anchor links in your Markdown renderer, or keep the web component and declare it in components.external
@11ty/eleventy-imgNo built-in equivalent. Copy images with their width and height, or generate variants in your own exec script
@11ty/eleventy-navigationA navigation array in src/lib/ and a page-aware header that sets aria-current from BASCIK_PAGE_PATH
HtmlBasePlugin and pathPrefixThe base option. Not exercised by the tutorial

Before and After: Home Page

The base blog's home page shows the three newest posts and a count of the rest.

njk
{# content/index.njk (Eleventy - before) #}
---js
const numberOfLatestPostsToShow = 3;
---
{% set postsCount = collections.posts | length %}
{% set latestPostsCount = postsCount | min(numberOfLatestPostsToShow) %}
<h1>Latest {{ latestPostsCount }} Post{% if latestPostsCount != 1 %}s{% endif %}</h1>

{% set postslist = collections.posts | head(-1 * numberOfLatestPostsToShow) %}
{% set postslistCounter = postsCount %}
{% include "postslist.njk" %}

{% set morePosts = postsCount - numberOfLatestPostsToShow %}
{% if morePosts > 0 %}
<p>{{ morePosts }} more post{% if morePosts != 1 %}s{% endif %} can be found in <a href="blog.njk">the archive</a>.</p>
{% endif %}

The template logic becomes one helper. loadPosts returns posts oldest first, sorted by date, with drafts removed from builds:

ts
// src/lib/render.ts (excerpt)
const plural = (count: number, word: string) => `${count} ${word}${count === 1 ? '' : 's'}`;

export function renderHome(posts: Post[], latest = 3): string {
  const shown = posts.slice(-latest);
  const more = posts.length - latest;
  const parts = [`<h1>Latest ${plural(shown.length, 'Post')}</h1>`, renderPostList(shown, posts.length)];
  if (more > 0) parts.push(`<p>${plural(more, 'more post')} can be found in <a href="/blog/">the archive</a>.</p>`);
  return parts.join('\n');
}
html
<!-- src/pages/index.html (Bascik - after) -->
<!DOCTYPE html>
<html lang="en">
<head>
  <site-head data-bascik-prop-title="Home - Acme Blog"></site-head>
</head>
<body>
  <site-header />
  <main id="main">
    <script data-bascik-build>
      import { loadPosts } from '@/lib/posts.ts';
      import { renderHome } from '@/lib/render.ts';

      export default async function () {
        return renderHome(await loadPosts());
      }
    </script>
  </main>
  <site-footer />
</body>
</html>

Post URLs keep Eleventy's trailing slash (/blog/firstpost/) because the post template is src/pages/blog/[slug]/index.html.