Skip to content

Latest commit

 

History

History
1532 lines (1288 loc) · 52.9 KB

README.md

File metadata and controls

1532 lines (1288 loc) · 52.9 KB

vite-plugin-minissg

Minissg (pronounce it as "missing" 😄) is a minimum-sized, configurable, and zero-JS static site generator, provided as a Vite plugin.

Unlike fully-featured frameworks, it aims to bring the bare functionality of JS and Vite, including its extensibility and performance, into static site generation. Minissg is carefully designed just as a Vite plugin, not as a framework, so that it does not hide anything in JS and Vite from the users. With Minissg, any decision and convention for static site generation are up to you; for example, you can write your static webpages with any of your favorite technologies including React, Preact, Solid, Svelte, Vue, Markdown, MDX, and even any combination of them, in exchange of a little effort to write some JS code and some configurations in vite.config.js.

The core part of Minissg consists only about 1,100 lines of code in TypeScript (actually, this README has more lines than Minissg). This small codebase allows you to easily understand what Minissg does and does not. Including this point, Minissg aims to be not an opinionated framework, but a transparent atmosphere, which does not lead you to anything but maximum freedom of static site programming.

Getting Started

Template projects using Minissg with Preact, React, Solid, Svelte, Vue, and MDX are available in this repository. See template directory for the full list of templates. You can start your project with one of these templates by downloading it. For example, by using tiged:

tiged uenob/minissg/template/preact my_project

After that, change directory to the new directory and install all dependencies by the npm command:

cd my_project
npm install

The following scripts are initially available:

  • npm run build for static site generation for production.
  • npm run serve for preview of the build result.
  • npm run dev for starting Vite's dev server.

Getting Started from Scratch

Hello, World

To start a Minissg project without any template, install Vite and Minissg by your favorite package manager:

npm install vite vite-plugin-minissg

And then, put Minissg in the plugins list of vite.config.js and specify at least one entry script in build.rollupOptions.input.

import { defineConfig } from "vite"
import minissg from "vite-plugin-minissg"

export default defineConfig({
  build: {
    rollupOptions: {
      input: "index.html.js" // put a script file here
    }
  },
  plugins: [
    minissg({
      // put your configuration here
    })
  ]
})

Write the following code and save it in index.html.js:

export default `<!DOCTYPE html>
<html>
  <head><title>My first Minissg site</title></head>
  <body><h1>Hello, Vite + Minissg!</h1></body>
</html>`;

We are now ready to build a site. Run vite build by your favorite package manager:

npx vite build

Vite runs twice automatically with the following message and generates an index.html file in dist directory:

vite v4.4.4 building SSR bundle for production...
✓ 2 modules transformed.
dist/index.html.mjs           0.18 kB
dist/assets/lib-d99b01dc.mjs  0.33 kB
✓ built in 146ms
vite v4.4.4 building for production...
✓ 1 modules transformed.
dist/index.html  0.12 kB │ gzip: 0.11 kB
✓ built in 16ms

The content of dist/index.html should be something like the following, which is exactly same as the string literal written in index.html.js:

<!DOCTYPE html>
<html>
  <head><title>My first Minissg site</title></head>
  <body><h1>Hello, Vite + Minissg!</h1></body>
</html>

Previewing and Dev Server

You can use Vite's dev server and preview server for the website development. To view your site with live reloading, run Vite's Dev Server by the following command:

npx vite

To preview the production site generated by vite build, execute the following command:

npx vite preview

See Vite's manual for details of these commands.

Multiple Page Generation

To generate multiple pages, do one of the following:

  1. Write a new file, say hello.txt.js,

    export default "Hello\n";

    and add it to build.rollupOptions.input.

    import { defineConfig } from "vite"
    import minissg from "vite-plugin-minissg"
    
    export default defineConfig({
      build: {
        rollupOptions: {
          input: ["index.html.js", "hello.txt.js"]
        }
      },
      plugins: [minissg()]
    })
  2. Define main function in index.html.js instead of the default export and indicate multiple routes in it.

    export const main = () => ({
      'index.html':
        {
          default: `<!DOCTYPE html>
            <html>
              <head><title>My first Minissg site</title></head>
              <body><h1>Hello, Vite + Minissg!</h1></body>
            </html>`
        },
      'hello.txt': { default: "Hello\n" }
    });

Using a Component Library

The page construction can be done with a component library. For your convenience, Minissg provides renderers that serialize components into HTML file contents. Here, we use React to do the same thing as the above. First of all, install React and its Vite plugin by the following command:

npm install react react-dom @vitejs/plugin-react @minissg/render-react

Write vite.config.js as follows:

import { defineConfig } from "vite"
import minissg from "vite-plugin-minissg"
import react from "@vitejs/plugin-react"  // ADDED
import minissgReact from "@minissg/render-react"  // ADDED

export default defineConfig({
  build: {
    rollupOptions: {
      input: "./index.html.jsx?render"   // MODIFIED. NOTE: "./" is mandatory
    }
  },
  plugins: [
    minissg({
      render: {
        "**/*.jsx": minissgReact()       // ADDED
      },
      plugins: () => [react()]           // ADDED
    })
  ]
})

The above config includes three important changes from the previous one:

  1. Specify a *.jsx file in build.rollupOptions.input with ?render query. The ?render query indicates that the component exported by this file must be serialized by the renderer specified in Minissg's render option.
  2. Associate *.jsx files to the renderer of React components (minissgReact) by setting Minissg's render option.
  3. Add the React plugin to Minissg's plugins option of the above form, not in that of Vite's config. The plugins put here are included in both the server-side and client-side run of Vite, whereas plugins specified in Vite's config are used only in the server-side run.

Write index.html.jsx like this:

export default function() {
  return (
    <html>
      <head><title>My first Minissg site</title></head>
      <body><h1>Hello, Vite + Minissg + React!</h1></body>
    </html>
  )
};

Run vite build and find dist/index.html created from the JSX file.

Authoring with Markdown

Minissg does not provide any capability to deal with Markdown files, but you can combine it with your favorite Markdown libraries. Say @mdx-js/rollup for such a Markdown processor. The Vite config must be extended as follows:

import { defineConfig } from "vite"
import react from "@vitejs/plugin-react"
import mdx from "@mdx-js/rollup"           // ADDED
import minissg from "vite-plugin-minissg"
import minissgReact from "@minissg/render-react"

export default defineConfig({
  build: {
    rollupOptions: {
      input: "./index.html.md?render"     // MODIFIED
    }
  },
  plugins: [
    minissg({
      render: {
        "**/*.{jsx,md}": minissgReact()   // MODIFIED
      },
      plugins: () => [
        react(),
        mdx()                             // ADDED
      ]
    })
  ]
})

Then, write a markdown file named index.html.md with the following content:

# My first Minissg site
Hello, Vite + Minissg + MDX!

And execute vite build to generate the page.

How It Works

Vite Runs Twice

Minissg actually does the following:

  1. Run Vite in SSR mode and bundle all the files specified in build.rollupOptions.input into a single JavaScript program.
  2. Traverse file dependencies in the generated program and determine the set of client-side codes and style sheets for each page to be generated.
  3. Execute the generated program and obtain the list of pages and their contents to be generated.
  4. Run Vite again to generate client-side codes, style sheets, and assets.

In what follows, we refer to the first and second run of Vite as server-side run and client-side run, respectively. We also refer to the program generated by or given to the first run as server-side code.

For integrity between the two runs, Minissg loads server-side code not only in server-side run but also client-side run. Loading server-side code in client-side run is needed to yield assets that are referred only from server-side code or are generated by some server-side plugins and used in client-side code. Assets generated in server-side run are all discarded for integrity. Only the files generated by client-side run are left in the destination site.

Module Tree

Server-side code consists of a collection of modules, which constitute the hierarchy of files in the website to be generated. The variation of a module is defined as follows in TypeScript:

type Module =
  | { main: (context: Readonly<Context>) => Module | PromiseLike<Module> }
  | { default: Content | PromiseLike<Content> }
  | Record<string, Module | PromiseLike<Module>>
  | Iterable<readonly [string, Module | PromiseLike<Module>]>;

export type Content =
  | string
  | ArrayBufferLike
  | ArrayBufferView
  | Blob
  | null
  | undefined

The definition of Context will be given later in Contextual Information of Modules section.

The Module type is the type of modules expected by Minissg. Intuitively:

  1. A module may have main function that returns another module. This allows the module to delegate file generation to another module, which is possibly dynamically created.
  2. A module may also have default value that gives Minissg the content of a destination file. The content can be given in several forms including string, Uint8Array, ArrayBuffer, and Blob. Except for nullish values, Content can be included in an argument of Blob constructor. This allows modules to generate any kind of files from various sources; for example, a module can download something by fetch API and pass its response as a Blob to Minissg. The null or undefined content means "not found." If a module have both main and default, main has precedence and default is ignored.
  3. A module may also be an Iterable object that enumerates multiple routing. In this case, each item in the Iterable object must be a pair (array with two elements) of a string and module, where the string is a relative path that will be joined with the name of currently requested file. See Static Routing section below for the details of name concatination.

Empty modules are simply ignored.

Through the main functions and mapping objects, the collection of modules constitute a tree structure. The root of this tree is the top-level module, which is a virtual module generated in accordance with Vite's build.rollupOptions.input config. The top-level module is constructed depending on the structure of build.rollupOptions.input by the following rules:

  1. If build.rollupOptions.input is a single string, the top-level module is a singleton mapping from the name of the given file to the module provided by the file. For example,
    { build: { rollupOptions: { input: "index.html.js" } } }
    means that index.html.js is the module providing the content of index.html file. This is equivalent to the following module:
    { "index.html": { main: () => import("./index.html.js") } }
  2. If it is an array of strings, the top-level modules is a mapping from their names to their modules. For example,
    { build: { rollupOptions: { input: ["index.html.js", "hello.txt.js"] } } }
    means the following module:
    {
      "index.html": { main: () => import("./index.html.js") },
      "hello.txt": { main: () => import("./hello.txt") }
    }
  3. If it is an object, Minissg uses it as the mapping from names to modules. For example, if the following config is given,
    { build: { rollupOptions: { input: { "index.html": "index.js" } } } }
    Minissg uses index.js to generate index.html, i.e., the top-level module should look like the following:
    { "index.html": { main: () => import("./index.js") } }

Static Routing

Each module is uniquely associated to a name of a destination file. In what follows, we refer to such a name as the name of a module.

The name of the top-level module is always index.html. For other modules, which must be child modules of a module, their names are computed from the name of their parent module by the following rule:

  1. The name of the module returned by main function is same as that of the module owning the main function.
  2. The name of a module associated to a relative path in a parent module is obtained by appending the relative path to the end of the name of the parent module. The appending is done with equating path fragment index.html with empty fragment. Detailed procedure of this appending is the following:
    1. If the name of the parent module ends with index.html fragment, eliminate it.
    2. If the relative path includes . fragment, eliminate all of them.
    3. If the name of the parent module does not end with / and the relative path is not empty, add / to the beginning of the relative path.
    4. Concatenate the output of the parent module and the relative path in this order.
    5. If the concatenation result ends with /, append index.html to the end of it.

For static site generation, Minissg visits all of the modules reachable from the top-level module. During this traversal, Minissg calls all main functions to determine the entire set of modules. After that, for each module that have effective default value, Minissg generates a file whose name is the name of the module and whose content is the default value.

Note that the name of a module is not always unique. If two modules has the same name and effective default values, the first-visited one precedes another. Strictly speaking, the precedence is determined by the pre-order of the entire module tree. Intuitively, the order of precedence is the order of modules appearing in a parent module.

A simple but powerful way to organize multiple modules is to use Vite's import.meta.glob feature. The following example defines the main function that includes all of the *.md files in the pages directory to generate */index.html files from them.

const mdFiles = import.meta.glob("./**/*.md", { query: { render: "" } });

// transform filenames *.md to */ and make modules with import functions
const modules = Object.entries(mdFiles).map(([filename, main]) => {
  return [filename.replace(/\.md$/, "/"), { main }]
});

export const main = () => modules;

By exploiting the fact that index.html and ./ fragments in a relative path are ignored, you can overlay a module tree with other trees. This is useful to separate files for each concern regardless of the hierarchy of destination files. A typical example is, as shown below, to separate the top page from other pages that are generated from Markdown files.

export const main = () => ({
  // Only the top page has a special construction.
  'index.html': { main: () => import("./index.jsx") },
  // But others have the same layout and generated in the same way.
  '.': { main: () => import("./pages.js") }
});

Renderers

Renderer is a Minissg's feature that transforms the default export of a file to a content of a generated file. To apply a renderer to a file, add ?render query to the import referring to the file.

For example, suppose that we are using Minissg with React. We usually write a React component as a separate file, say Count.jsx, like the following:

import { useState } from "react";

export default function Count({ init }) {
  const [count, setCount] = useState(init ?? 0);
  return (
    <button onClick={() => setCount(c => c + 1)}>
      count is {count}
    </button>
  );
};

The default export of this file is a React component, which is a function, not of type Content given in the above type definitions. Therefore, without any additional conversion, Minissg cannot generate a file from this module. The purpose of renderers is to provide this kind of conversion. By applying the renderer for React components to this file, we obtain the serialized string of this component.

To apply a renderer to a file, add ?render query to its import like this:

import content from "./Count.jsx?render";

The content variable contains a PromiseLike object of a string that can be accepted by Minissg as a file content.

The association between files and their renderers is given in Minissg's render option in vite.config.js. The render option associates a glob pattern to a renderer. Minissg searches for a renderer of a particular file in accordance with this association. See Plugin Options section for details of the render option.

Minissg provides several renderers for popular component systems in separate packages. To use renderers, install the following packages and import them in vite.config.js:

  • @minissg/render-preact for Preact components.
  • @minissg/render-react for React components.
  • @minissg/render-solid for Solid components.
  • @minissg/render-svelte for Svelte components.
  • @minissg/render-vue for Vue components.

Note that using renderers is not essential; you can avoid it by writing your own serializer by your hand and include it in server-side code. You can even write custom renderers and give it to Minissg through Minissg's render option. See User-defined Renderer and Hydration section for details.

DOCTYPE Insertion

HTML files should start with <!DOCTYPE html> but ?render does not emit it. To add <!DOCTYPE html> at the beginning of the result of ?render, use ?render&doctype query instead of ?render.

For example, suppose that we have index.html.jsx of the following code:

export default () => (
  <html>
    <head><title>hello</title></head>
    <body>world</body>
  </html>
);

and import this file as follows:

import content from "./index.html.jsx?render&doctype";

The value of content is shown below:

<!DOCTYPE html><html><head><title>hello</title></head><body>world</doby></html>

If we don't have doctype query in the import, <!DOCTYPE html> at the beginning of the above is lost.

render always precedes doctype in the query; therefore, ?render&doctype and ?doctype&render has the same meaning.

Style Sheets

Each generated HTML file may have static links to CSS files. The set of style sheets of each page is the set of all of the *.css files (or other style sheet files supported by Vite) imported in server-side code in the middle of loading the module corresponding to the file.

While this looks similar to Vite's default handing of style sheets, Minissg provides more; if a dynamic import occurs in the middle of the path to a module, dynamically imported *.css files are also included in the set of style sheets of that module. The set of style sheets is computed independently for each module. For example, suppose the following three files:

  1. index.html.js
    import "./index.css";
    export const main = () => ({
      "foo.html" => { main: () => import("./foo.html.js") },
      "bar.html" => { main: () => import("./bar.html.js") }
    });
  2. foo.html.js
    import "./foo.css";
    export default "<!DOCTYPE html><html> ... </html>";
  3. bar.html.js
    import "./bar.css";
    export default "<!DOCTYPE html><html> ... </html>";

The index.html.js file provides the route to two modules foo.html and bar.html provided by foo.html.js and bar.html.js, respectively. The foo.html.js and bar.html.js have default export and consequently foo.html and bar.html are generated as a result. The set of style sheets of these two HTML files depends on the path of execution to these modules as follows.

  1. Regardless of the two modules, index.html.js must be executed and therefore index.css is inevitably imported. Consequently, a link to index.css is included in both foo.html and bar.html.
  2. foo.css is imported only when foo.html.js is dynamically imported. Hence, foo.css is additionally included in foo.html. Since dynamic import of foo.html.js is not relevant to bar.html, foo.css is not included in bar.html.
  3. Similarly, bar.css is included only in bar.html.

As a result, the following two links are included in foo.html:

<link rel="stylesheet" href="index.css">
<link rel="stylesheet" href="foo.css">

and are the following in bar.html:

<link rel="stylesheet" href="index.css">
<link rel="stylesheet" href="bar.css">

Note that these tags are not what is included exactly in the final output. Vite transforms *.css files, bundles them appropriately, and then injects the optimized result in the generated HTML files.

Client-side Code

All the modules in server-side code are just for server-side file generation and therefore are not left in the output. To include some code in the generated site in order to execute it on client side, import it in server-side code with ?client query.

For example, suppose the following two files:

  1. index.html.js
    import "./foo.js?client";
    export default `<!DOCTYPE html>
    <html><head><title>Hello</title></head><body></body></html>`;
  2. foo.js
    document.body.appendChild(document.createTextNode('Hi!'));

By building the site up with index.html.js, we find dist/index.html that refers a script like the following:

<!DOCTYPE html>
<html>
  <head>
    <title>Hello</title>
    <script type="module" src="/assets/foo-fd50dfe5.js"></script>
  </head>
  <body></body>
</html>

The set of scripts to be included in each page is determined by the same manner as style sheets; all the imports with ?client query executed during loading a module is included in the output of that module.

The client-side code is processed by Vite as well as server-side code. This means that you can write client-side code in any language that Vite (or one of its plugin) supports. Vite translates client-side code and minifies all its chunks as usual. Minissg does not throw anything more at Vite, but does not take anything more from Vite.

Partial Hydration

Minissg provides a simple partial hydration mechanism in the sense of island architecture for several component systems. To enable partial hydration for a component, import the component with ?hydrate query. The code imported with ?hydrate query is included in both server-side and client-side code to embed its initial view in the static file and to hydrate the view in the web browsers.

As an example, here we attempt to use the Count.jsx presented in Renderers section. At first, To enable partial hydration feature for *.jsx files, associate **/*.jsx to React renderer in Minissg plugin's render option.

By importing Count.jsx with ?hydrate query as follows, we turn on the hydration of this component:

import Count from "./Count.jsx?hydrate";

export default function() {
  return <html><head></head><body><Count init={3} /></body></html>;
};

This results in the following output:

<html>
  <head>
    <script type="module" src="/assets/hydrate-7c5f4dec.js"></script>
  </head>
  <body>
    <div data-hydrate="g_zZoKsE" data-hydrate-args="{&quot;init&quot;:3}">
      <button>count is 3</button>
    </div>
  </body>
</html>

The /assets/hydrate-7c5f4dec.js file is the top-level client-side code that hydrates the Count component. It searches for the element with data-hydrate="g_zZoKsE" attribute for the target of hydration, where "g_zZoKsE" is the unique identifier of the Count component. The data-hydrate-args attribute holds the serialized argument of Count component, which is passed to the component for hydration.

Note that this is not the only way for hydration in Minissg. You can hydrate a component in a different way than ?hydrate by writing it in your program. Minissg does not force you anything.

Advanced Features

Configuring Client-side Run

As described above, Vite runs twice for static site generation. The two runs are for different purposes and therefore may have different settings. The top-level config of vite.config.js is used only in server-side run, not in client-side run. The config of client-side run is automatically generated by Missing based on the top-level config and the result of server-side run. To tweak the config of client-side run, Minissg provides config option that will be merged into the config of client-side run. For example, to turn on the minify option only in client-side run, write it in the config option as follows:

import { defineConfig } from "vite"

export default defineConfig({
  build: {
    rollupOptions: {
      input: "./index.html.js"
    }
  },
  plugins: [
    minissg({
      config: {
        build: {
          minify: true  // enable minify only on client-side run
        }
      }
    })
  ]
})

Minissg computes the config of client-side run from the following three sources: the top-level config, the results of server-side run, and the config option of the Minissg plugin. Almost all the settings in the top-level config are inherited to client-side run except for the following:

  • plugins are never shared between the two runs. Plugins must be instantiated for each run. To enable the same set of plugins in both runs, which is a usual requirement, duplicate it in both the top-level config and Minissg's config, as seen in the following example:
    import { defineConfig } from "vite"
    import react from "@vitejs/plugin-react"
    
    export default defineConfig({
      ...
      plugins: [
        react(),  // plugin for server-side run
        minissg({
          ...
          config: {
            plugins: [react()]  // plugin for client-side run
          }
        })
      ]
    })
    As a shorthand of this, Minissg provides plugin option for convenience. Set it to a function returning an array of plugins and the function will call twice to instantiate plugins for each runs. The following example is equivalent to the above:
    import { defineConfig } from "vite"
    import react from "@vitejs/plugin-react"
    
    export default defineConfig({
      ...
      plugins: [
        minissg({
          ...
          plugins: () => [react()]  // plugin for both runs
        })
      ]
    })
  • The following options of the top-level config are overwritten in client-side run with the result of server-side run: root, base, mode, build.emptyOutDir, and build.rollupOptions.input. You can overwrite them again by setting them in the config option, but it usually breaks the integrity of two runs; do it at your own risk.

Generating Data for Client-side Code

Minissg allows server-side code to generate specific data for each client-side code. For this purpose, importing with ?client query provides us with a mutable object that holds data to be passed to the client-side code. Client-side code can refer to such data by importing a virtual module named virtual:minissg/self?client.

Suppose that we have a client-side code foo.js and attempt to pass some data to it from server-side code. This can be done by manipulating the default export of foo.js?client. For example, the following code

import data from "./foo.js?client";

data["bar"] = "baz";

puts a string "baz" with the key "bar" in the object to be passed to foo.js. Minissg serialize this object in JSON and generate a client-side module that provides the JSON object. Because of this implementation, the values put in this object must be serializable by JSON.stringify.

In client-side code, import virtual:minissg/self?client in foo.js to have an access to the object passed from the server-side code. For example, in foo.js, the following client-side code

import data from "virtual:minissg/self?client";

console.log(data["bar"]);

will print baz in the web browser's console.

The object imported by ?client query has id property as its initial content. The value of the id property is a string of a unique identifier of this client-side module. You can overwrite or delete it if it is not needed. A supposed usage of id is to find particular HTML elements generated by server-side code from client-side code.

Renderer for a Module Itself

Sometimes it is convenient if a module can obtain the renderer associated to itself. A special module named virtual:minissg/self?renderer provides it. The default export of virtual:minissg/self?renderer is the rendering function of the importer. The actual type of the rendering function depends on its definition.

A typical example of using this feature is that a module has multiple children of the same type and renders them in the same way. The following is an example of JSX file that aggregates MDX files:

import render from "virtual:minissg/self?renderer";

// common layout for all MDX files
const Layout = ({ children }) => {
  return <html><head></head><body>{children}</body></html>;
};

const pages = import.meta.glob("./**/*.mdx");
const modules = Object.entries(pages).map(([filename, load]) => {
  const main = async () => {
    // load an MDX file and compose it with Layout.
    const Component = await load();
    const Page = () => <Layout><Component /></Layout>;
    // serialize the composed component.
    return render(Page);
  };
  return [filename.replace(/mdx$/, "html"), { main }]
});

export const main = () => modules;

User-defined Renderers and Hydration

A renderer is actually an object representing a collection of functions that return code in a string. Minissg calls one of these function in accordance with ?render and/or ?hydrate queries to obtain the code that implements the feature specified by those queries. The type of renderers is given below in TypeScript:

type Renderer = {
  render?: {
    server?: (arg: { parameter: string }) => string | PromiseLike<string>;
    client?: (arg: { parameter: string }) => string | PromiseLike<string>;
  };
  hydrate?: {
    server: (arg: HydrateArg) => string | PromiseLike<string>;
    client: (arg: HydrateArg) => string | PromiseLike<string>;
  };
};

type HydrateArg = {
  id: string;
  moduleId: string;
  parameter: string;
};

A renderer can provide two properties render and hydrate, which are associated to ?render and ?hydrate queries, respectively. Both of them may have two variants: server for server-side code generation, and client for client-side. All of the functions in a renderer must return a string of the source code of an ES6 module. Except for hydrate.server, the code must be written in vanilla JavaScript. The code returned by hydrate.client must be written in the same language as the file that ?hydrate query is given.

Each function in render must return a string of the source code of an ES6 module whose default export is the rendering function, which serializes the given data or component into a format specified in the Content type described in Module Tree section. The render functions takes one argument, namely parameter, which holds the value of ?render query.

The code generated by hydrate.server must be a component that wraps the original component with hydration target container. The hydrate.server function is given an argument of HydrateArg type, which has three properties: id for a unique identifier of this component, moduleId for the identifier of the original file, and parameter for the value of ?hydrate query. Both hydrate.server and hydrate.client functions are called with the identical argument for each import.

The code generated by hydrate.client must be a JS code that can be embedded in an <script type="module"> element of an HTML document. The code must import the same component as server-side code, determine the target element of hydration, and perform the hydration.

Mixing Components of Different Systems

Minissg never dismiss any mixture of different component systems not only in a single site but even in a single page. Since string is the most common representation of components, by applying renderers to components, serializing them into strings, and combining them as strings, you can freely mix any component of different component systems in a page.

This is obviously true even if the partial hydration feature is used. Vite and Rollup's bundling mechanism and Minissg's ?hydrate feature computes the dependencies of files and libraries appropriately. As a natural result, the generated site includes the minimum set of library codes and hydration wrappers.

Contextual Information of Modules

The main function of a module is given an object that offers the following information:

  • module: The module itself.
  • moduleName: The full name of the module. This would be convenient to compute the canonical URL of each module. This is a ModuleName, which is a class having a property path and three instance methods fileName, join, and isIn. It is ensured that the path property does not start with /.
  • request: information for dynamic routing and server-side rendering. If it is given, the main function may dynamically create and return a module depending on it. Note that Minissg is a static site generator, not a web application framework. Currently, we do not have any plan to enhance this feature.
  • path: relative path string from the parent module, or undefined if the module is either the root or made by the main function of the parent module.
  • parent: the context of the parent module, or undefined if this module is the root of the module tree.
  • loaded: the set of Vite's module identifiers that are dynamically imported in the current context. See Manipulating the Effect of Dynamic Imports for the usage of this information.

The type of the argument of the main function, Context, is defined as follows in TypeScript:

type Context = {
  moduleName: ModuleName;
  module: Module;
  request?: Readonly<Request> | undefined;
  path?: string | undefined;
  parent?: Readonly<Context> | undefined;
  loaded?: Set<string> | undefined;
}

type Request = {
  requestName: ModuleName;
  incoming: import("node:http").IncomingMessage;
}

type ModuleName = {
  readonly path: string
  fileName(): string
  join(path: string): ModuleName;
  isIn(other: ModuleName): boolean;
}

Manipulating the Effect of Dynamic Imports

As described in Style Sheets section, Minissg exploits the effect of dynamic imports to associate style sheets and other assets to the generated pages. However, we sometimes want fine-grained control over the set of assets imported, such as importing some modules without associating them with any specific assets. Typical examples include the case when you want to read the frontmatter of an MDX module regardless of any relationship between the module and current page.

For this purpose, Minissg makes loaded set public in the Context structure. By manipulating the loaded set, you can manipulate the set of assets associated to the current context. For example, the following function ignores assets loaded during the execution of the given function:

function peek(context, f) {
  const { loaded } = context
  const original = new Set(loaded ?? [])  // save the current loaded set
  try {
    f()  // this may add some modules in the loaded set
  } finally {
    loaded.clear()  // revert the loaded set to the saved set
    for (const i of original) loaded.add(i)
  }
}

Debugging Server-side Code

When an error occurred during the execution of server-side code, Vite prints the current stack trace and then aborts. Source map helps you find where the error occurred in server-side code.

To make source maps available in vite build, the following settings are needed:

  1. Turn on build.sourcemap in vite.config.js in order to provide the sourcemaps of server-side code.
  2. Set NODE_OPTIONS environment variable to --enable-source-maps.

Note that the build.sourcemap option changes the build result. If you do not want to include source maps in your product, turn it on only in the development mode.

Sometimes you maybe want to check the generated code by server-side run. To prevent Vite from removing it, set Minissg's clean option to false. This is just for development use; clean should not be false in production mode.

Plugin Options

clean

type default
boolean true

The clean option is the flag indicating whether or not Minissg removes intermediate chunks, such as server-side code.

This is provided for debugging. See Debugging Server-side Code section for details.

config

type default
import("vite").UserOptions {}

The config option provides additional configuration for client-side run. See Configuring Client-side Run section for details.

plugins

type default
() => import("vite").PluginOptions () => []

The plugins option has a function that returns an array of plugins used in both server-side and client-side run. See Configuring Client-side Run section for details.

render

type default
Record<string, Renderer> | Iterable<RenderItem> {}

This associates source files to renderers. The association must be specified in one of the following forms:

  1. An object whose property names are glob patterns and values are renderers.
  2. An array of objects, each of which is of the following type:
    type RenderItem = {
      include?: (string | RegExp)[] | string | RegExp | null | undefined;
      exclude?: (string | RegExp)[] | string | RegExp | null | undefined;
      renderer?: Renderer | null | undefined;
    }
    The renderer is associated to files that matches with include but does not match with exclude.

Related Works

  • Frameworks for a particular UI system including Next.js, Nuxt.js, and SvelteKit, to name a few.

    Each of them is coupled with particular component systems. This design provides a simple solution that draws the full power of component-based web programming.

    They often, however, bring us a bunch of conventions, such as rules of naming files and variables, and thick abstraction that covers all of the underlying functionalities. Since they brings things into outside of programming language, we need additional knowledge of a framework to read code and analyze the semantics or correctness of the entire project. Unfortunately, particular knowledge of a framework typically becomes decayed much faster than that of a language because such knowledge cannot be easily applied to other frameworks except for the concept or overall idea of the framework. The users of a framework must be in the same boat as the framework whether or not they desired so. This limits the sustainability and lifetime of a project to those of the framework without much effort to follow the trend and migrate to brand-new ones.

    Our observation regarding this issue is that separation of concerns would help us exit from this kind of hell. As known from five decades, breaking software into simple and independent components and organize it just as a combination of them makes it easy for us to write and maintain the software. We would like to apply this principle to website authoring so that the users, not the authors of frameworks, can control their separation of concerns. Based on this observation, we provide Minissg, which only facilitates applying modern features of JavaScript to website authoring, instead of providing all-in-one product with many out-of-the-box features.

  • Astro

    Astro is also a web framework, but unlike ones mentioned above, it is independent of any particular UI system. To achieve their goals including this independence, it introduces Astro component, which is their own description system for server-side components. Astro component encapsulates server-side generation, client-side codes, and style sheets in a file for each server-side component. Client-side component can be written in any framework; Astro component can place them appropriately in a page in a manner of island architecture. On the other hand, Astro component is a new language that is (at least currently) tightly coupled with the Astro framework. Although Astro component is resemble to JSX, its detailed semantics is different from JSX; for example, at least in Astro 2.5 or earlier, it is not capable of defining local function component. This leads us to learn a new language and develop a set of tools for that language when using the framework.

    Minissg takes orthogonal approach to Astro one; instead of introducing a new language for a particular purpose, it aims to bring the full features of existing language into such a purpose. To this end, we attempt to enhance a general-purpose front-end tool, namely Vite, to site generation. If an issue would be found under this approach, it must be dealt with an issue of a language, not a particular framework, and therefore the solution of it would be beneficial to all the users of the language.

  • Gatsby

    Gatsby is a web framework specialized in static generation of single page application. One of its attractive benefit comes from its ecosystem. Since over 2,000 plugins are available, most of user's intention can be realized easily and quickly just by searching for a plugin and installing it.

    If everything goes well, this plugin-based approach gives us an effective solution. However, once an anomaly arises, such as a situation that an error occurred in plugins or the system needs to be fine-tuned to a specific purpose, the users need to dive deeply into the internal of the system. In addition, Gatsby plugins work only on Gatsby and therefore the effort of developing such plugins are closed in the Gatsby ecosystem.

    Our intention is that we can avoid this kind of issues by avoiding making separate platform and ecosystem from those of the underlying language and its general-purpose tools. We develop Minissg as a foundation that bridges the gap between the general-purpose language and tools and website authoring. Minissg is designed carefully so that it can be separate from any libraries and frameworks as much as possible and therefore the users can combine their favorite components on top of it.

  • minista

    This should be a good evidence of the fact that all-in-one but large frameworks are not always needed to do web authoring. This framework consists of about 6,000 lines code, which is much smaller than all the frameworks mentioned above, but all the features desired by its author are certainly included in it. One of the reason why this is achieved with such a small code base is that general-purpose libraries and tools in JavaScript has been matured as well as language itself and therefore the users can freely combine them without any cumbersome description to fulfill their hope. We would like to accelerate this direction by Minissg.

  • Translators from templates to websites, such as, in JS, Eleventy and Lume.

    They facilitates making websites by writing text or Markdown files and applying them to templates. They carefully avoid from letting the users write any code so that the users can concentrate on authoring their contents. This zero-code authoring makes building websites very easy if the structure of a website is not complicated, for example, if all pages are made from distinct sources and/or no client-side script is included. To do more things than those, the users need to either write programs in a template language, which is a domain-specific language for templating and therefore is more restricted than full-scale languages, or to write plugins in a host language, which needs specific knowledge about the internals of the systems. Minissg's policy is orthogonal to their ones; it forces the users to write their own code as well as their content so that they can construct their websites with the leverage of a modern general purpose language.

  • Tropical

    Tropical is a template of Vite project for static site generation, which suggests a combination of Vite and other standard tools that is as powerful as but more flexible than large frameworks. In contrast, Minissg does not suggest any combination of tools, but aims to be a basis for programming the entire site, which brings website authoring under full control of a modern general purpose programming language without cumbersome coding. Combining tools and libraries for an application is just a usual functionality of such a language. We hope that Minissg would help the users make their own combination of tools like Tropical for each of their own projects.

  • vite-plugin-ssr

    Minissg shares the same goal as vite-plugin-ssr including doing just one thing well, independence from any framework and tool, simplicity, transparency, small footprint, but fully-featured. A difference between the two is that Minissg pursues this goal more extremely.

    Minissg is being developed based on our belief that a full-scale programming language is the most sophisticated source of abstraction. For example, tree construction is a part of the principle of computing and therefore any high-level language allows us to do it in a straightforward and concise way. In website development, page routing is within the variations of tree construction. Hence, we design Minissg's routing mechanism as module tree construction by JS code instead of file system routing, which vite-plugin-ssr adopts. From our perspective, file system routing puts the task that a programming language can do very well outside of the language. It also introduces some conventions of file placement and naming, which not only restricts the usage of file system but also requires the users to learn something in addition to the language. We believe that this is not satisfactory and therefore we propose an alternative to it in Minissg.

    Avoiding introduction of conventions and abstractions for a particular purpose is the core principle of the design of Minissg. This policy also makes Minissg smaller; Missing consists of about 1,000 lines of code, which is more than eight times smaller than vite-plugin-ssr.

Tips and Notes

  • It is possible to import the same file both in server-side and client-side, but it may cause some problems in building related to some Rollup plugins. Minissg sometimes add ?MINISSG-COPY query at the end of file names and duplicate codes in order to separate server-side codes from client-side resources. This changes the suffix of a file name, preventing some Rollup plugins from accepting a file with a particular suffix. Examples of such plugins include @mdx-js/rollup. For example, applying partial hydration to an MDX component usually cause this issue.

    If you encounter this issue and want to avoid it, configure plugins so that they can accept files even with the ?MINISSG-DUP suffix. For example, set the following options to @mdx-js/rollup:

    mdx({ mdxExtensions: ['.mdx', '.mdx?MINISSG-DUP' ] })
  • Minissg often prevents Vite's optimizeDeps feature from working as expected for some reasons including the following:

    • Putting ?render in build.rollupOptions.input hides entry files from the optimizeDeps feature because optimizeDeps interprets each name in build.rollupOptions.input as a wildcard.
    • Even if optimizeDeps can find entries, those in Minissg is for server-side codes, whereas optimizeDeps is for client-side codes.
    • Minissg introduces virtual modules on the boundary between server-side and client-side. Because optimizeDeps does not dive into virtual modules, it cannot reach any client-side codes. Hence, Minissg avoids optimizeDeps by default by giving an empty array to optimizeDeps.entries option.

    To make optimizeDeps with Minissg work as expected, you should need to list package names to be optimized in optimizeDeps.include options. For example, if you use Vue in your project and apply optimizeDeps to it, add the following to vite.config.js:

    export default defineConfig({
      optimizedeps: { include: ['vue'] }
    })

    Some Vite plugins, such as @vitejs/plugin-react and @preact/preset-vite, add an appropriate list of packages to optimizeDeps.include automatically.

  • Minissg preserves the Vite's original HTML support. Actually, Minissg deals with HTML files in the same way as asset files. If an HTML file is found in build.rollupOptions.input or an import, it is transformed by Vite (and its plugins) and included in the destination site as expected.

  • CSS modules in server-side code usually does not work as expected. Since Vite 5.2.0, CSS modules are tree-shaken: if a CSS module is imported but not referred from any client-side code, the CSS module is eliminated completely by tree-shaking at client side run.

    Even if we could turn off the tree-shaking, an empty JS chunk would be generated unexpectedly. Such an empty chunk appears because of the following reason. The JS code generated from a CSS module is so simple that minifier can remove it at all when it is not used. While the content of the chunk corresponding to the CSS module becomes empty by minifier, the chunk itself cannot be removed because tree-shaking is turned off.

    To avoid confusion, it is recommended not to use CSS module with Minissg. Instead of CSS modules, use another technologies for modular styling such as linaria.

License

MIT