Marko

Marko + Rollup

This is Marko’s official integration plugin for the Rollup bundler.

Installation

npm install --save-dev \
  @marko/rollup \
  rollup \
  @rollup/plugin-node-resolve \
  @rollup/plugin-commonjs

Note: The Marko runtime is CommonJS, so don’t forget the @rollup/plugin-commonjs package!

Configuration

@marko/rollup exports two methods for use in Rollup configuration files: .browser() and .server().

You probably want to use both, since that’ll get you…

  • Automatic input entrypoint configuration for route-based bundle splitting
  • Complete control over asset loading with the <rollup> tag
  • The strengths behind why Marko exists in the first place: cooperation between servers and browsers for high performance in both

ProTip: You could use only .browser() or only .server() to build a completely client-side-rendered or server-side-rendered app. That would be a little odd, but you could.

Config example

import nodeResolve from "@rollup/plugin-node-resolve";
import commonjs from "@rollup/plugin-commonjs";
import marko from "@marko/rollup";

const sharedPlugins = [
  commonjs({
    extensions: [".js", ".marko"]
  }),
  // If using Marko’s `style {}` blocks, you’ll need an appropriate plugin, like npmjs.com/rollup-plugin-postcss
  postcss({ external: true })
]

const serverAssetsConfig = {
  input: "src/start-server.js",
  plugins: [
    marko.server(),
    nodeResolve({ preferBuiltins: true })
    ...sharedPlugins
  ]
};
const browsersAssetsConfig = {
  plugins: [
    marko.browser(),
    nodeResolve({ browser: true })
    ...sharedPlugins
  ]
};

export default [serverAssetsConfig, browsersAssetsConfig];

Advanced config example

The following configuration file is long and hairy, which may be upsetting to some viewers. However, it does show how to accomplish the following:

  • Support for Rollup’s watch mode
  • A bundle analyzer
  • The ability to import JSON files to use their data
  • The ability to import image files to use their asset URLs for img[src] and such
  • Dead-code elimination for development-only code
  • Static compression of assets for something like NGiNX’s gzip_static
  • A CSS preprocessor (Sass, in this case)
  • Browserslist to automatically configure:
    • Babel for JS transpilation
    • Autoprefixer for CSS transpilation
Big ugly production-esque Rollup config
import { builtinModules } from "module";
import path from "path";
import autoprefixer from "autoprefixer";
import babelPlugin from "@rollup/plugin-babel";
import commonjsPlugin from "@rollup/plugin-commonjs";
import jsonPlugin from "@rollup/plugin-json";
import markoPlugin from "@marko/rollup";
import nodeResolvePlugin from "@rollup/plugin-node-resolve";
import replacePlugin from "@rollup/plugin-replace";
import runPlugin from "@rollup/plugin-run";
import stylesPlugin from "rollup-plugin-styles";
import urlPlugin from "@rollup/plugin-url";
import pkg from "./package.json";

const __DEV__ = process.env.NODE_ENV === "development";
const __PROD__ = !__DEV__;

const isWatch = Boolean(process.env.ROLLUP_WATCH);

const publicPath = "/s/"; // Guess what character is only 5 bits under HPACK
const assetFileNames = "[name]-[hash][extname]";

const externalDependencies = [
  ...Object.keys(pkg.dependencies),
  ...builtinModules,
];

process.env.SASS_PATH = "./:./node_modules";

export default (async () => [
  compiler("server", {
    input: "index.js",
    output: {
      dir: "built/server/",
      assetFileNames: `../browser/${assetFileNames}`,
      format: "cjs",
      sourcemap: true,
    },
    external: (id) =>
      externalDependencies.some(
        (dependency) => id === dependency || id.startsWith(dependency + "/"),
      ),
    plugins: [isWatch && runPlugin({ execArgv: ["--enable-source-maps"] })],
  }),

  compiler("browser", {
    output: {
      dir: "built/browser/",
      chunkFileNames: __PROD__ ? "[name]-[hash].js" : null,
      entryFileNames: __PROD__ ? "[name]-[hash].js" : null,
      assetFileNames,
      sourcemap: true,
      sourcemapExcludeSources: __PROD__,
    },
    plugins: [
      stylesPlugin({
        mode: "extract",
        sourceMap: true,
        config: {
          target: "browserslist:css",
          plugins: [autoprefixer({ env: "css" })],
        },
        minimize: __PROD__,
        url: {
          publicPath,
          hash: assetFileNames,
        },
      }),
      __PROD__ && (await import("rollup-plugin-terser")).terser(),
      __PROD__ &&
        (await import("rollup-plugin-gzip")).default({
          filter: /\.(?:js|css|svg|json|xml|txt)$/,
          minSize: 1024,
          gzipOptions: {
            level: 9,
            memLevel: 9,
          },
        }),
      __PROD__ &&
        !isWatch &&
        (await import("rollup-plugin-visualizer")).default(),
      __PROD__ &&
        !isWatch && {
          name: "bundle-visualizer-location",
          writeBundle() {
            console.info(
              `📊 Bundle visualizer at \x1b[4;36mfile://${path.join(
                __dirname,
                "../../",
                bundleAnalyzerFilename,
              )}\x1b[0m`,
            );
          },
        },
    ],
  }),
])();

function compiler(target, config) {
  const isBrowser = target === "browser";
  const browserslistEnv = isBrowser ? "js" : "server";
  const babelConfig = {
    comments: false,
    browserslistEnv,
    compact: false,
    babelrc: false,
    caller: { target },
  };
  if (isBrowser) {
    babelConfig.presets = [
      [
        "@babel/preset-env",
        {
          browserslistEnv,
          bugfixes: true,
        },
      ],
    ];
  }

  return {
    ...config,
    preserveEntrySignatures: false,
    plugins: [
      markoPlugin[target]({ babelConfig }),
      nodeResolvePlugin({
        browser: isBrowser,
        preferBuiltins: !isBrowser,
      }),
      commonjsPlugin(),
      replacePlugin({
        preventAssignment: true,
        values: { __DEV__, __PROD__ },
      }),
      babelPlugin({
        babelHelpers: "bundled",
        ...babelConfig,
      }),
      jsonPlugin(),
      urlPlugin({
        publicPath,
        destDir: "built/browser/",
        fileName: assetFileNames,
        include: "**/*.{svg,png,jpg,jpeg}",
        limit: 0, // Never Base64 & inline
        emitFiles: !isBrowser,
      }),
      ...config.plugins,
    ],
  };
}

<rollup> tag

Using both .server() and .browser() enables the <rollup> tag, which gives you complete control over how your app loads assets. That lets you do things like:

The <rollup> tag provides two tag parameters:

  1. entry is the generated input string that the server plugin gave to the browser plugin. You can use it to find the corresponding entry chunk from Rollup’s output (the next parameter).

  2. output is an array of AssetInfo | ChunkInfo objects with most of the data returned from Rollup's generateBundle hook. Some properties are omitted, like code and map, since they’re often too large to inline directly. However, each chunk also has a size property, to let you filter out empty chunks, inline code yourself below a certain size, or other delightful devilishness.

For example, using the entry name and properties of output items to load scripts:

<head>
  <rollup|entry, output|>
    $ const entryChunk = output.find(chunk => chunk.name === entry);

    <if(entryChunk.size /* only load non-empty JS entry points */)>
      <for|fileName| of=entryChunk.imports>
        <link rel="modulepreload" href=fileName />
      </for>

      <script async type="module" src=entryChunk.fileName></script>
    </if>
  </rollup>
</head>
head
  rollup|entry, output|
    $ const entryChunk = output.find((chunk) => chunk.name === entry);

    if(entryChunk.size /* only load non-empty JS entry points */)
      for|fileName| of=entryChunk.imports
        link rel="modulepreload" href=fileName

      script async type="module" src=entryChunk.fileName

Note: It’s up to you to transform the chunk data (also called the manifest) into <link>s, <script>s, and other HTML to load assets. Opting into complete control means we can’t do any of it for you.

If your Rollup browser config contains multiple output options, or you have multiple browser configs, every output’s chunk is passed to the <rollup> tag.

For example, if you have both esm and iife build outputs configured:

{
  output: [
    { dir: "dist/iife", format: "iife" },
    { dir: "dist/esm", format: "esm" },
  ];
}

…you could cross-reference assets from both…

<rollup|entry, iifeOutput, esmOutput|>
  $ const iifeEntryChunk = iifeOutput.find(chunk => chunk.name === entry);
  $ const esmEntryChunk = esmOutput.find(chunk => chunk.name === entry);

  <script src=esmEntryChunk.fileName type="module" async></script>
  <script src=iifeEntryChunk.fileName nomodule async></script>
</rollup>
rollup|entry, iifeOutput, esmOutput|
  $ const iifeEntryChunk = iifeOutput.find((chunk) => chunk.name === entry);
  $ const esmEntryChunk = esmOutput.find((chunk) => chunk.name === entry);

  script src=esmEntryChunk.fileName type="module" async
  script src=iifeEntryChunk.fileName nomodule async

…and boom: you now have a module/nomodule setup.

Options

options.babelConfig

Both the .server() and .browser() plugins accept this option.

You can manually override the builtin Babel configuration by passing a babelConfig object. By default, Babel’s regular config file resolution will be used.

marko.browser({
  babelConfig: {
    presets: ["@babel/preset-env"],
  },
});

options.runtimeId

Both the .server() and .browser() plugins accept this option. In fact, you really want to use it with both simultaneously.

In some cases, you may want to embed multiple isolated copies of Marko on the page. (If you can’t think of why, then don’t worry about this option.)

Since Marko uses some window properties to initialize, multiple instances can cause issues. For example, by default Marko checks window.$components for server-rendered hydration. Usually you can change these window properties by rendering with { $global: { runtimeId: "MY_MARKO_RUNTIME_ID" } } as input on the server, but since @marko/rollup usually writes the autoinitialization code for you, instead this plugin exposes a runtimeId option to automatically set $global.runtimeId to initialize properly in the browser:

const runtimeId = "MY_MARKO_RUNTIME_ID";
// Make the `runtimeId` the same across `server` and `browser`, or it’ll error!
marko.server({ runtimeId });
marko.browser({ runtimeId });

options.serialize

This option is only available for the .browser() plugin. It lets you inspect and transform the output chunks before they’re passed to the <rollup> tag.

For example, if you did want to include the code property from the Rollup chunk — say, to inline code small enough that it’s not worth the overhead of an HTTP request, you’d try something like the following:

marko.browser({
  serialize(output) {
    return output.map(({ type, fileName, isEntry, code }) =>
      type === "asset"
        ? { type, fileName }
        : {
            type,
            name,
            isEntry,
            fileName,
            // only inline code chunks below 1kB
            inline: code.trim().length < 1024 && code,
          },
    );
  },
});
EDIT on GitHub

Contributors

Helpful? You can thank these awesome people! You can also edit this doc if you see any issues or want to improve it.

Chat in Marko's Discord Server