Quick Overview
What follows is the custom plugin I built for Angular Material Blocks that enables importing raw file contents directly for code previews.
The Short Version
If you're only after the plugin implementation, head straight to the plugin creation and usage part.
Choosing NX
Before diving into Angular Material Blocks, I devoted time to evaluating whether to scaffold the project with Angular CLI or NX.
Angular CLI handles many scenarios well, including setups with multiple libraries and applications. Yet NX brings a richer set of built-in enhancements. While the full comparison is available here, these points were especially useful during my work:
- Generators and Executors
- Running builds and tests Only for Affected Projects
- Extensible Plugin Architecture
- Environment variable support through
.envfiles - Enforced Module Boundaries
Need for a raw loader plugin
While building Angular Material Blocks, I required a way to fetch file contents as strings with minimal effort for displaying code examples.
Take the Badge 1 block as an illustration: it presents both a live preview and the unprocessed source from component files (HTML templates, TypeScript logic, and CSS/SCSS).
NX doesn't offer built-in raw content importing, so I built a custom solution to read and import the literal contents of files—primarily HTML, TypeScript, and CSS/SCSS sources.
Adopted usage pattern
Before writing any code, I settled on how the plugin would be invoked. I particularly like how vite allows importing assets as strings, shown below:
import shaderString from './shader.glsl?raw'
That approach guided my goal: design a plugin named raw that lets you import file contents as plain text by appending the ?raw query parameter.
Building the Plugin and Integrating It
The examples below rely on the @nx/angular:application executor because it exposes support for custom ESBuild plugins. As a result, the code is specific to that executor or the @nx/angular:browser-esbuild executor.
Implementing the raw-loader Plugin
Create a new file named plugins/raw-loader-plugin.js and paste the following code into it:
import { readFileSync } from 'fs';
import * as path from 'path';
const rawLoaderPlugin = {
name: 'raw',
setup(build) {
build.onResolve({ filter: /\?raw$/ }, args => {
return {
path: path.isAbsolute(args.path)
? args.path
: path.join(args.resolveDir, args.path),
namespace: 'raw-loader',
};
});
build.onLoad({ filter: /\?raw$/, namespace: 'raw-loader' }, async args => {
return {
contents: readFileSync(args.path.replace(/\?raw$/, '')),
loader: 'text',
};
});
},
};
module.exports = rawLoaderPlugin;
Here is what each part of that code does:
setup(build) { ... }- This method acts as the entry point for any ESBuild plugin. It receives abuildobject, which exposes the API used to hook into the build process. Insidesetup, the plugin declares which imports it intends to intercept.Resolution Hook - The
onResolvehook uses the regular expression/?raw$/to catch any import path that terminates with the?rawquery string.Path Resolution: Once a
?rawimport is found, the plugin computes the file's absolute location. If the import path is not already absolute, it combines it with the resolver's current directory.Custom Namespace: To keep these imports away from the standard module pipeline, the resolved
?rawimports are placed into the'raw-loader'namespace.Loading Hook: The
onLoadhook targets files within the'raw-loader'namespace and filters them based on the same?rawpattern.Content Retrieval: The plugin eliminates the
?rawsuffix from the path usingreplace(/\?raw$/, ''), then uses Node.js'sreadFileSyncto fetch the file's actual contents.Text Loader: By returning the content with
loader: 'text', the plugin instructs ESBuild to interpret the value as plain text rather than attempting to parse it as code.Outcome: This approach enables me to import the raw source of a file directly as a string, bypassing any compilation steps and obtaining the exact file content.
Registering the raw-loader Plugin
To make the plugin available to your Angular application, add it to the plugins array found in the targets.build.options section of your project's project.json file. Further examples can be found here:
{
"targets": {
"build": {
"executor": "@nx/angular:application",
"options": {
"plugins": [
"plugins/raw-loader-plugin.js"
]
}
}
}
}
Importing Files with the raw-loader Plugin
Once configured, add the ?raw query parameter after the file extension in your import statement to access the file's content:
import deviceServiceContent from 'path/to/device.service.ts?raw';
// prints raw TS content of device.service.ts file
console.log(deviceServiceContent)
Addressing TypeScript Errors
Directly applying the raw-loader as described will trigger TypeScript errors similar to this:
Cannot find module 'path/to/device.service.ts?raw' or its corresponding type declarations.
The error appears for these reasons:
No Built-in Types: TypeScript lacks definitions for the
?rawimport syntax, leaving the type of the imported module unknown.Non-standard Syntax: The
?rawsuffix is a custom feature of the ESBuild plugin, which TypeScript does not recognize as a valid import mechanism.Build-Time vs. Type-Checking: While ESBuild processes the
?rawimports during the build, TypeScript's type checker operates independently and is unaware of this transformation.Inferred Type Failure: TypeScript cannot deduce that the variable receiving the import, such as
deviceServiceContent, should be astring, because it doesn't understand that?rawimports produce the file's text content.
Two methods are available to resolve or suppress these errors:
Adding a type definition (preferred)
Adding an inline comment
Using a Type Definition (Recommended)
Start by creating a types.d.ts file in the project's root and add the following declaration:
declare module '*?raw' {
const content: string;
export default content;
}
Next, register the new types.d.ts file by adding it to the include array within tsconfig.base.json:
{ "include": ["types.d.ts"]}
Remember to apply the same configuration changes to the tsconfig files of every application and library in your workspace.
After making these changes, you might need to restart your editor for the TypeScript server to recognize the new definitions and stop reporting errors.
Using an Inline Comment
If creating a type definition isn't appealing, you can insert an inline comment directly above the import statement instead:
// @ts-expect-error TypeScript cannot provide types for raw-loader
import deviceServiceContent from 'path/to/device.service.ts?raw';
The @ts-expect-error directive is a signal to TypeScript to suppress any type error that appears on the very next line. This tells the compiler to accept that the import will function at runtime, even if it can't verify the type statically.
Replicating the same behavior in Angular CLI workspaces
For teams working in an Angular CLI workspace who need comparable functionality — such as pulling in the raw text of any file — the application builder introduced in Angular 17 ships with this capability out of the box.
Adjusting loading logic via the loader import attribute
The loader import attribute can be paired with an import statement to alter how a module is loaded.
// @ts-expect-error TypeScript cannot provide types based on attributes yet
import contents from './some-file.svg' with { loader: 'text' };
TypeScript does not yet offer type definitions that correspond to import attribute values. Until that changes, developers will have to rely on @ts-expect-error or @ts-ignore, or maintain separate type declaration files provided the same loader attribute is used consistently across imports.
Additional details are available on angular.dev.
Angular Material Blocks
For a limited period, I am offering a 20% discount on Personal & Teams licenses with lifetime access to Angular Material Blocks! Be sure to explore it and take advantage of this offer.

