Modern web development has become incredibly powerful. But also increasingly complicated.

Many projects begin by installing hundreds of megabytes of dependencies before writing a single page. Frameworks, bundlers, routers, templating systems, CSS tooling, and runtime libraries all solve important problems, but they also introduce additional complexity.

I wanted something different.

I wanted to build websites that start with plain HTML, while still providing the features developers expect today:

  • Reusable components
  • Markdown support
  • TypeScript
  • CSS and JavaScript bundling
  • Internationalization (i18n)
  • Content collections
  • RSS/Atom feeds
  • Sitemap generation
  • Fast development builds

That idea became Rino.js.

What is Rino.js?

Rino.js is an HTML-first website compiler for building static websites, documentation, blogs, portfolios, company websites, and other content driven projects.

Instead of introducing a custom templating language or requiring a frontend framework, Rino.js treats HTML as the primary language. Pages remain valid HTML while additional functionality is added through a small set of build-time conventions.

The goal is simple:

Write HTML. Generate optimized static websites.

Why HTML First?

HTML has existed for decades, yet modern web development often treats it as something generated by another language.

Rino.js takes the opposite approach.

Instead of writing components in JSX or another template language, components are simply HTML files.

<component rino-import="header"></component>

Enter fullscreen mode Exit fullscreen mode

That's all it takes.

The compiler replaces the component during the build, producing plain static HTML with no runtime dependency.

Starting Rino.js

Rino.js has a command that is designed to provide default project.

npm create rino@latest

Enter fullscreen mode Exit fullscreen mode

Project Shape

A Rino.js project usually looks like this:

my-site/
  rino-config.js
  dev.js
  generate.js
  feed.js
  sitemap.js
  backoffice.js
  pages/
    index.html
    about.html
  components/
    header.html
    footer.html
  public/
    images/
      photo.webp
  scripts/
    export/
      app.js
      dashboard.ts
  styles/
    export/
      site.css
  mds/
    intro.md
  contents/
    en/
      blog/
        1-first-post.md
  content-theme/
    en/
      content.html
      content-list.html
  i18n/
    en/
      index.json
    ko/
      index.json

Enter fullscreen mode Exit fullscreen mode

Only the folders you need are required. For a small static site, pages/, components/, public/, scripts/export/, and styles/export/ may be enough.

Configuration

The project configuration lives in rino-config.js.

export default {
  dist: "./dist",
  port: 3000,
  site: {
    url: "https://example.com",
  },
  sitemap: ["https://example.com/custom-page"],
  i18n: {
    defaultLocale: "en",
    locales: ["en", "ko"],
  },
};

Enter fullscreen mode Exit fullscreen mode

dist controls the output folder, port controls the development server, site.url is used for sitemap and feeds, sitemap adds extra URLs, and i18n controls localized page generation.

Pages

Files in pages/ become HTML files in the output.

pages/index.html        -> dist/index.html
pages/about.html        -> dist/about.html
pages/blog/index.html   -> dist/blog/index.html

Enter fullscreen mode Exit fullscreen mode

A page is normal HTML with optional Rino syntax:

<!doctype html>
<html>
  <head>
    <title><lang>site.title</lang></title>
    <link rel="stylesheet" href="/styles/site.css" />
    <script src="/scripts/app.js"></script>
  </head>
  <body>
    <component rino-import="header"></component>

    <main>
      <h1><lang>home.heading</lang></h1>
      <script rino-type="md" rino-import="intro" rino-tag="section"></script>
    </main>

    <component rino-import="footer" rino-tag="footer"></component>
  </body>
</html>

Enter fullscreen mode Exit fullscreen mode

Components

Components live in components/. Use <component> to render one inside a page or another component.

<component rino-import="header"></component>

Enter fullscreen mode Exit fullscreen mode

This loads:

components/header.html

Enter fullscreen mode Exit fullscreen mode

Nested component paths are supported:

<component rino-import="layout/sidebar"></component>

Enter fullscreen mode Exit fullscreen mode

This loads:

components/layout/sidebar.html

Enter fullscreen mode Exit fullscreen mode

Use rino-tag when you want to wrap the component output in an element and pass attributes to that wrapper.

<component
  rino-import="button"
  rino-tag="button"
  type="button"
  class="button"
  onclick="alert('Hello')"
></component>

Enter fullscreen mode Exit fullscreen mode

If components/button.html contains:

Click me

Enter fullscreen mode Exit fullscreen mode

The generated HTML becomes:

<button type="button" class="button" onclick="alert('Hello')">Click me</button>

Enter fullscreen mode Exit fullscreen mode

In version 3, use rino-import. The older rino-path syntax is not supported.

Markdown

Rino.js can render Markdown snippets from mds/.

mds/intro.md

Enter fullscreen mode Exit fullscreen mode

<script rino-type="md" rino-import="intro" rino-tag="section" class="intro"></script>

Enter fullscreen mode Exit fullscreen mode

That renders mds/intro.md inside a <section>.

You can also write Markdown inline:

<script rino-type="markdown" rino-tag="article" type="text/markdown">
  ## Hello from Markdown

  This content is rendered during the build.
</script>

Enter fullscreen mode Exit fullscreen mode

Supported Markdown types are md and markdown.

Template Scripts

Template scripts run during generation. They print HTML into the generated page with console.log.

JavaScript template script:

<script rino-type="js" type="text/javascript">
  console.log("<ul>");
  for (const item of ["Home", "Blog", "About"]) {
    console.log(`<li>${item}</li>`);
  }
  console.log("</ul>");
</script>

Enter fullscreen mode Exit fullscreen mode

TypeScript template script:

<script rino-type="ts" type="text/typescript">
  const message: string = "Generated with TypeScript";
  console.log(`<p>${message}</p>`);
</script>

Enter fullscreen mode Exit fullscreen mode

Template scripts can use Node.js imports:

<script rino-type="js" type="text/javascript">
  import os from "os";
  console.log(`<p>Built on ${os.type()}</p>`);
</script>

Enter fullscreen mode Exit fullscreen mode

In version 3, template execution is faster because Rino.js reuses an isolated child-process runner instead of creating a new process for every template script tag.

Browser Scripts

Browser scripts are normal browser JavaScript. Put source files in scripts/export/.

scripts/export/app.js -> dist/scripts/app.js
scripts/export/app.ts -> dist/scripts/app.js

Enter fullscreen mode Exit fullscreen mode

Reference the generated file from HTML:

<script src="/scripts/app.js"></script>

Enter fullscreen mode Exit fullscreen mode

Example JavaScript:

document.querySelector("button")?.addEventListener("click", () => {
  console.log("Clicked");
});

Enter fullscreen mode Exit fullscreen mode

Example TypeScript:

const button = document.querySelector<HTMLButtonElement>("button");

button?.addEventListener("click", () => {
  console.log("Clicked with TypeScript");
});

Enter fullscreen mode Exit fullscreen mode

Rino.js bundles scripts and minifies the output. Minification is useful for smaller files.

CSS

Put CSS files in styles/export/.

styles/export/site.css -> dist/styles/site.css

Enter fullscreen mode Exit fullscreen mode

@import "../theme.css";

body {
  font-family: system-ui, sans-serif;
}

Enter fullscreen mode Exit fullscreen mode

Reference the generated CSS:

<link rel="stylesheet" href="/styles/site.css" />

Enter fullscreen mode Exit fullscreen mode

Rino.js resolves local CSS imports, skips circular imports, skips URL imports, and minifies CSS output.

Public Assets

Files in public/ are copied to the output root.

public/favicon.ico       -> dist/favicon.ico
public/images/photo.webp -> dist/images/photo.webp

Enter fullscreen mode Exit fullscreen mode

Use root-relative paths:

<img src="/images/photo.webp" alt="Photo" />
<link rel="icon" href="/favicon.ico" />

Enter fullscreen mode Exit fullscreen mode

Inline style, javascript and typescript Exports

rino-export lets a page or component keep related CSS and javascript close to its HTML while still generating external files.

Exported tags are removed from final HTML and written to generated assets.

CSS Export

<section class="callout">
  <h2>Hello</h2>
</section>

<style rino-export="/site.css">
  .callout {
    border: 1px solid #ddd;
    padding: 1rem;
  }
</style>

Enter fullscreen mode Exit fullscreen mode

This writes:

dist/styles/site.css

Enter fullscreen mode Exit fullscreen mode

JavaScript Export

<script rino-export="/site.js">
  console.log("Exported browser script");

  window.openMenu = function () {
    document.body.classList.toggle("menu-open");
  };
</script>

Enter fullscreen mode Exit fullscreen mode

This writes:

dist/scripts/site.js

Enter fullscreen mode Exit fullscreen mode

TypeScript Export

<script rino-export="/site.ts" type="text/typescript">
  const message: string = "Exported TypeScript";
  console.log(message);
</script>

Enter fullscreen mode Exit fullscreen mode

This writes JavaScript:

dist/scripts/site.js

Enter fullscreen mode Exit fullscreen mode

If code outside the bundle needs to call a function, attach it to window.

<script rino-export="/site.ts" type="text/typescript">
  (window as any).testExportTypescript = function (): void {
    console.log("Called from outside");
  };
</script>

Enter fullscreen mode Exit fullscreen mode

Then call it from HTML:

<button onclick="window.testExportTypescript()">Run</button>

Enter fullscreen mode Exit fullscreen mode

Multiple tags can export to the same file. Rino.js appends unique blocks and ignores exact duplicate blocks.

i18n

Use <lang> tags in pages and components:

<h1><lang>name</lang></h1>
<p><lang>body.hero.copy</lang></p>
<p><lang>items[0].title</lang></p>

Enter fullscreen mode Exit fullscreen mode

Create matching JSON files in i18n/{locale}/.

pages/index.html
i18n/en/index.json
i18n/ko/index.json

Enter fullscreen mode Exit fullscreen mode

Example of i18n/en/index.json:

{
  "name": "Rino.js",
  "body": {
    "hero": {
      "copy": "Simple static websites from HTML."
    }
  },
  "items": [
    {
      "title": "First item"
    }
  ]
}

Enter fullscreen mode Exit fullscreen mode

The default locale is generated at the normal page path. Other locales are generated under their locale prefix, such as /ko/index.html.

Missing keys remain in the HTML instead of breaking the build. To keep a literal language tag, escape it:

\<lang>name\</lang>

Enter fullscreen mode Exit fullscreen mode

Content Collections

Rino.js can build Markdown content collections such as blogs.

contents/en/blog/1-first-post.md

Enter fullscreen mode Exit fullscreen mode

Markdown content can start with JSON metadata inside an HTML comment:

<!--
{
  "title": "Welcome to the Blog",
  "time": "2026-07-29T00:00:00.000Z",
  "description": "The first post on our Rino.js blog."
}
-->

# Hello World

This is the first post.

Enter fullscreen mode Exit fullscreen mode

This generates:

dist/contents/en/blog/1-first-post.html

Enter fullscreen mode Exit fullscreen mode

The matching template lives at:

content-theme/en/content.html

Enter fullscreen mode Exit fullscreen mode

Example content template:

<!doctype html>
<html lang="en">
  <head>
    <title>{{ content.title }}</title>
  </head>
  <body>
    <main>
      <h1>{{ content.title }}</h1>
      <p>{{ content.time }}</p>
      <article>{{ content.body }}</article>
    </main>
  </body>
</html>

Enter fullscreen mode Exit fullscreen mode

You can access metadata and generated fields:

{{ content.title }}
{{ content.description }}
{{ content.body }}
{{ content.urlPath }}
{{ content.nearby[0].title }}

Enter fullscreen mode Exit fullscreen mode

For loops, use a template script:

<script rino-type="js" type="text/javascript">
  const args = process.argv;
  const content = JSON.parse(args[args.length - 1]);

  console.log("<ul>");
  for (const post of content.nearby || []) {
    console.log(`<li><a href="${post.link}">${post.title}</a></li>`);
  }
  console.log("</ul>");
</script>

Enter fullscreen mode Exit fullscreen mode

Content Lists

Content list templates live next to content templates:

content-theme/en/content-list.html

Enter fullscreen mode Exit fullscreen mode

Rino.js creates paginated list pages for each category:

dist/contents-list/en/blog/blog-1.html

Enter fullscreen mode Exit fullscreen mode

Example:

<ol>
  <script rino-type="js" type="text/javascript">
    const args = process.argv;
    const data = JSON.parse(args[args.length - 1]);

    for (const item of data.contentList) {
      console.log(`<li><a href="${item.link}">${item.title}</a></li>`);
    }
  </script>
</ol>

Enter fullscreen mode Exit fullscreen mode

Available data includes:

{{ contentList[0].title }}
{{ pagination.prevLink }}
{{ pagination.nextLink }}

Enter fullscreen mode Exit fullscreen mode

Template scripts are usually the better option for rendering arrays.

Sitemap and Feeds

Rino.js can generate a sitemap from pages, localized pages, content pages, and extra config URLs.

import { generateProjectSitemapFile } from "rinojs";
import config from "./rino-config.js";

await generateProjectSitemapFile(process.cwd(), config);

Enter fullscreen mode Exit fullscreen mode

It can also generate RSS and Atom feeds from contents/.

import { generateProjectFeedFiles } from "rinojs";
import config from "./rino-config.js";

await generateProjectFeedFiles(process.cwd(), config);

Enter fullscreen mode Exit fullscreen mode

Typical outputs:

dist/sitemap.xml
dist/rss.xml
dist/atom.xml
dist/rss-en.xml
dist/atom-en.xml

Enter fullscreen mode Exit fullscreen mode

My Goal

Rather than building another frontend framework, I wanted to explore how far plain HTML could go with a lightweight compiler.

The result is a workflow that keeps HTML at the center while adding practical build-time features when they're needed.

Version 3 is a major milestone, but it's only the beginning. There are many ideas for future releases, and I'm looking forward to continuing to improve the project based on real world use.