How to handle links when using twig loader

127 Views Asked by At

I use twig loader to process twig files. But it doesn't handle resource links like html loader does.

There is a similar question, but the proposed solutions did not help me. When using html-loader extract-loader and twig-loader together, a problem has arisen, because there may be nesting inside the template, and html-loader receives a twig file that has not yet been processed

Webpack Config:

type WebpackBuildMode = "development" | "production";

interface WebpackBuildPaths {
    readonly pages: string;
    readonly src: string;
    readonly output: string;
    readonly svgo: string;
    readonly public: string;
}

interface WebpackBuildEnv {
    readonly mode: WebpackBuildMode,
    readonly port: number;
    readonly svg: boolean;
}

interface WebpackBuildOptions {
    readonly mode: WebpackBuildMode;
    readonly paths: WebpackBuildPaths;
    readonly isDev: boolean;
    readonly port: number;
    readonly isSvg: boolean;
}

const buildEntry = (options: WebpackBuildOptions): webpack.Configuration["entry"] => {
    const {paths} = options;
    
    const entry = fs.readdirSync(paths.pages).reduce((acc, dir) => {
        acc.push([
            dir,
            path.resolve(paths.pages, dir, 'script.ts')
        ]);
        
        return acc;
    }, []);
    
    return Object.fromEntries(entry);
};

const buildPlugins = (options: WebpackBuildOptions): webpack.Configuration["plugins"] => {
    const {paths, isSvg, isDev} = options;
    
    const html = fs.readdirSync(paths.pages).filter(dir => dir !== 'base').map(dir => (
        new HtmlWebpackPlugin({
            inject: false,
            template: path.resolve(paths.pages, dir, "template.twig"),
            templateParameters: (compilation, assets, assetTags, options) => {
                const compilationAssets = compilation.getAssets();
                
                const sprites = compilationAssets.filter((asset: any) => asset.name.includes('sprites/'));
                
                return {
                    compilation,
                    webpackConfig: compilation.options,
                    htmlWebpackPlugin: {
                        tags: assetTags,
                        files: {
                            ...assets,
                            svgSprites: sprites.map((sprite: any) => ([
                                sprite.name.split('/')[1],
                                sprite.source._valueAsString
                            ])),
                        },
                        options,
                    },
                };
            },
            filename: `${dir}.html`,
            minify: false,
        })
    ));
    
    const plugins: webpack.Configuration["plugins"] = [
        new MiniCssExtractPlugin({
            filename: "[name].css",
        }),
        new SvgChunkWebpackPlugin({
            filename: "sprites/[name].svg",
            generateSpritesPreview: true,
            svgstoreConfig: {
                svgAttrs: {
                    'aria-hidden': true,
                    style: 'position: absolute; width: 0; height: 0; overflow: hidden;'
                }
            }
        }),
    ];
    
    if (!isSvg) {
        plugins.push(...html);
    }
    
    return plugins;
};

const buildLoaders = (options: WebpackBuildOptions): webpack.Configuration["module"]["rules"] => {
    const {isDev, isSvg, paths} = options;
    
    const tsLoader = {
        test: /\.ts$/,
        use: "ts-loader",
        exclude: /node_modules/,
    };
    
    const twigLoader:  webpack.RuleSetRule = {
        test: /\.twig$/,
        use: [
            {
                loader: "html-loader",
                options: {
                    sources: {
                        list: [
                            {
                                tag: "img",
                                attribute: "src",
                                type: "src",
                            },
                        ],
                    },
                    minimize: false,
                },
            },
            {
                loader: "twig-loader",
            },
        ],
    };
    
    const styleLoader = {
        test: /\.s[ac]ss$/i,
        use: [
            MiniCssExtractPlugin.loader,
            "css-loader",
            "sass-loader",
        ],
    };
    
    const fontLoader = {
        test: /\.(woff|woff2|eot|ttf|otf)$/i,
        type: "asset/resource",
        generator: {
            filename: 'assets/fonts/[name][ext]'
        }
    };
    
    const svgLoader = {
        test: /\.svg$/i,
        include: [
            path.resolve(paths.src, "shared", "assets", "icons")
        ],
        use: [
            {
                loader: (SvgChunkWebpackPlugin as any).loader,
                options: {
                    configFile: paths.svgo,
                },
            },
        ],
    };
    
    const assetsLoader = {
        test: /\.(png|jpg|jpeg|gif|svg)$/i,
        type: "asset/resource",
        exclude: [
            path.resolve(paths.src, "shared", "assets", "icons")
        ],
        generator: {
            outputPath: (pathData: any) => {
                const dirname = path.dirname(pathData.filename);
                let subDir = '';
                
                if (dirname.includes('src/shared/assets/images')) {
                    subDir = "assets/images/" + dirname.replace('src/shared/assets/images', '') + "/";
                }
                
                return subDir;
            },
            publicPath: (pathData: any) => {
                const dirname = path.dirname(pathData.filename);
                let subDir = '';
                
                if (dirname.includes('src/shared/assets/images')) {
                    subDir = "assets/images/" + dirname.replace(/src\/shared\/assets\/images(\/?)/ig, '') + "/";
                }
                
                return subDir;
            },
            filename: '[name][ext]'
        }
    };
    
    const loaders: webpack.Configuration["module"]["rules"] = [
        styleLoader,
        fontLoader,
        assetsLoader,
        svgLoader,
        tsLoader,
    ];
    
    if (!isSvg) {
        loaders.push(twigLoader);
    }
    
    return loaders;
};

const buildDevServer = (options: WebpackBuildOptions): DevServerConfiguration => {
    const {port} = options;
    
    return {
        port,
        open: true,
        hot: 'only',
    }
}

const buildResolve = (options: WebpackBuildOptions): webpack.Configuration["resolve"] => {
    const {paths} = options;
    
    return {
        extensions: [".ts", ".js"]
    };
};

const buildWebpackConfig = (options: WebpackBuildOptions): webpack.Configuration => {
    const {mode, isDev, isSvg, paths} = options;
    
    return {
        mode: mode,
        entry: buildEntry(options),
        plugins: buildPlugins(options),
        module: {
            rules: buildLoaders(options),
        },
        devtool: isDev ? 'inline-source-map' : undefined,
        devServer: isDev && !isSvg ? buildDevServer(options) : undefined,
        resolve: buildResolve(options),
        output: {
            path: paths.output,
            filename: '[name].js',
            clean: true,
        },
        optimization: {
            runtimeChunk: "single",
        }
    };
};

const config = (env: WebpackBuildEnv): webpack.Configuration => {
    const mode = env.mode;
    const isSvg = env.svg;
    const port = env.port || 3000;
    const isDev = mode === "development";
    
    const paths: WebpackBuildPaths = {
        src: path.resolve(__dirname, "src"),
        output: path.resolve(__dirname, "build"),
        pages: path.resolve(__dirname, "src", "pages"),
        svgo: path.resolve(__dirname, "config", "svgo", "svgo.config.ts"),
        public: path.resolve(__dirname, "public"),
    };
    
    return buildWebpackConfig({
        isDev,
        isSvg,
        mode,
        port,
        paths,
    });
};

export default config;

Package.json:

{
  "name": "krep-comp",
  "version": "1.0.0",
  "description": "",
  "private": true,
  "scripts": {
    "build": "webpack --env mode=production --env svg && webpack --env mode=production"
  },
  "keywords": [],
  "license": "ISC",
  "devDependencies": {
    "@types/node": "^20.8.4",
    "@types/webpack": "^5.28.3",
    "css-loader": "^6.8.1",
    "html-loader": "^4.2.0",
    "html-webpack-plugin": "^5.5.3",
    "mini-css-extract-plugin": "^2.7.6",
    "sass": "^1.69.2",
    "sass-loader": "^13.3.2",
    "style-loader": "^3.3.3",
    "svg-chunk-webpack-plugin": "^4.0.2",
    "ts-loader": "^9.5.0",
    "ts-node": "^10.9.1",
    "typescript": "^5.2.2",
    "webpack": "^5.88.2",
    "webpack-cli": "^5.1.4",
    "webpack-dev-server": "^4.15.1",
    "twig-loader": "^0.5.5"
  }
}

Project`s structure:

├── webpack.config.ts
├── package.json
├── src
|   ├── app
|   |   ├── main
|   |   |   ├── template.twig
|   |   |   ├── script.ts
|   ├── shared
|   |   ├── assets
|   |   |   ├── images
|   |   |   |   ├── test.jpg

Twig tempalte

<!doctype html>
<html lang="{% block lang %}ru{% endblock %}">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>{% block title %}{% endblock %}</title>

    {% for js in htmlWebpackPlugin.files.js %}
        {% if js|split('.')[0] == htmlWebpackPlugin.options.filename|split('.')[0] %}
            <script defer="defer" src="{{ js }}"></script>
        {% endif %}
    {% endfor %}

    {% for css in htmlWebpackPlugin.files.css %}
        {% if css|split('.')[0] == htmlWebpackPlugin.options.filename|split('.')[0] %}
            <link href="{{ css }}" rel="stylesheet">
        {% endif %}
    {% endfor %}
</head>
<body>
    {% block content %}{% endblock %}

    <img src="/src/shared/assets/images/test.jpg">

    {% for sprite in htmlWebpackPlugin.files.svgSprites %}
        {% if sprite[0]|split('.')[0] == htmlWebpackPlugin.options.filename|split('.')[0] %}
            {{ sprite[1] }}
        {% endif %}
    {% endfor %}
</body>
</html>
1

There are 1 best solutions below

0
On

To handle source files of scripts, styles, images, etc. in the twig template, you can use the HTML Bundler Plugin for Webpack. This plugin resolves references of assets in any template including the Twig.

For example, there is a simple twig template:

<!DOCTYPE html>
<html>
  <head>
    {# source style file relative to template #}
    <link href="./styles.scss" rel="stylesheet" />
    {# source script file relative to template #}
    <script src="./script.ts" defer="defer"></script>
  </head>
  <body>
    <h1>Hello Twig!</h1>
    {% block content %}{% endblock %}
    
    {# source image file using Webpack alias `@images`, or a relative path #}
    <img src="@images/test.jpg" />

    {# you can inline SVG content directly in HML #}
    <img src="@images/sprite.svg?inline" />

    {# to extends or include, you can use the namespaces, or a relative path #}
    {% include "@partials/footer.twig" %}
  </body>
</html>

The simple Webpack config:

const path = require('path');
const HtmlBundlerPlugin = require('html-bundler-webpack-plugin');

module.exports = {
  resolve: {
    alias: {
      // aliases used in template
      '@images': path.join(__dirname, 'src/shared/assets/images/'),
    },
  },
  plugins: [
    new HtmlBundlerPlugin({
      // define a relative or absolute path to entry templates
      entry: 'src/views/',
      // - OR - define many templates manually
      entry: {
        // the key is an output file path w/o .html
        index: 'src/views/page/home.twig' // => dist/index.html
      },
      preprocessor: 'twig', // use TwigJS templating engine
      preprocessorOptions: {
        // aliases used for extends/include
        namespaces: {
          partials: 'src/views/partials/',
        },
      },
      data: {
        // you can define here global variables used in all templates
      },
      js: {
        // output filename of compiled JavaScript, used if `inline` option is false (defaults)
        filename: 'assets/js/[name].[contenthash:8].js',
        //inline: true, // inlines JS into HTML
      },
      css: {
        // output filename of extracted CSS, used if `inline` option is false (defaults)
        filename: 'assets/css/[name].[contenthash:8].css',
        //inline: true, // inlines CSS into HTML
      },
    }),
  ],
  module: {
    rules: [
      {
        test: /\.(ts)$/,
        use: 'ts-loader',
      },
      {
        test: /\.(css|sass|scss)$/,
        use: ['css-loader', 'sass-loader'],
      },
      {
        test: /\.(ico|png|jp?g|webp|svg)$/,
        type: 'asset/resource',
        generator: {
          filename: 'assets/img/[name].[contenthash:8][ext]'
        }
      },
      {
        test: /\.(woff|woff2|eot|ttf|otf)$/i,
        type: 'asset/resource',
        generator: {
          filename: 'assets/fonts/[name][ext]'
        }
      },
    ],
  },
};

The plugin can detect templates in a path automatically.

For advanced use case, e.g. if you want dynamically generate a custom output html filename based on dirname, you can use the filename entry option as a function.

The generated HTML will be looks like:

<html>
  <head>
    <link href="assets/css/styles.05e4dd86.css" rel="stylesheet" />
    <script src="assets/js/script.f4b855d8.js" defer="defer"></script>
  </head>
  <body>
    <h1>Hello Twig!</h1>
    <img src="assets/img/test.58b43bd8.jpg" />
    <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 96 960 960" width="48" height="48">
      ...SVG sprite...
    </svg>
    <div>My included footer</div>
  </body>
</html>

The plugin can inline SVG as content and images as base64 into HTML.

This plugin resolves source files in templates (like html-loader), extracts CSS (like mini-css-extract-plugin), JS, renders Twig templates. So, additional plugins and loaders such as html-webpack-plugin, mini-css-extract-plugin, html-loader, twig-loader are not required.