Skip to content

Source locations

CORA’s build plugin writes each host element’s place in the project’s source into the page, as its data-cora-source attribute. A host element is one the page renders as itself, such as a <button>, an <img> or a custom element like <share-widget>, rather than a component such as <Header>. Which tags are host elements follows how your framework compiles them, as each framework’s section below says.

cora scan and cora-mcp’s scans read the attribute, so a finding on a stamped element names the file and line to fix. In an Angular app, Angular’s own compiler writes each element’s place instead, as its data-ng-source-location attribute, which the scans read too.

The plugin comes with @vispero/cora, from the subpath of your bundler, in a project that has installed cora. Each takes no options: the project’s CORA config says when it stamps.

Bundler Subpath Stamps Left unset, stamps Where to list it Registered by cora init
Vite, and Nuxt, Astro and SvelteKit @vispero/cora/vite JSX, in .js files too, Lit templates, Vue, Svelte and Astro components under the dev server anywhere yes
webpack, Rspack, Rsbuild @vispero/cora/webpack, /rspack, /rsbuild JSX, in .js files too, Lit templates, Vue templates and Svelte components in development mode anywhere yes
Rollup @vispero/cora/rollup JSX, in .js files too, Lit templates, Vue templates and Svelte components in watch mode anywhere yes
Rolldown @vispero/cora/rolldown JSX, in .js files too, Lit templates, Vue templates and Svelte components in watch mode anywhere yes
esbuild @vispero/cora/esbuild JSX, in .js files too, and Lit templates never: set sourceStamps after the plugins that load your JSX or Lit files no: a build is a script
Angular CLI none: Angular’s compiler option Angular templates, in .html or .svg files and inline not by sourceStamps: in the configurations whose tsconfig turns it on, development as cora init sets it up a tsconfig the configuration names yes

cora init registers the plugin in the config the bundler loads, where its default export, or a CommonJS config’s module.exports, is an object literal or defineConfig of one, imported from the bundler’s own package, a function that returns one, or a name for one that the file declares once, as webpack’s own scaffold writes, or, for webpack, Rspack, Rollup and Rolldown, a list of them: a CommonJS config requires the plugin’s .default, such as require('@vispero/cora/webpack').default. It leaves any other config to you, and names this page, such as one whose function returns its config from a branch, one it imports, a list with a config it cannot edit, one with a getter or a setter, or one whose plugins, or an item of them, a later line could change. In a project that lists one of these bundlers or frameworks but holds none of its configs in the working directory, it points you here instead, unless a tool that brings the bundler along is set up there: Vite, Nuxt or Astro for Rollup and Rolldown, and Rsbuild for Rspack.

In a Vite project, register @vispero/cora/vite in the Vite config, as cora init does. In a Nuxt or Astro project, it goes in the framework’s own config, as Nuxt and Astro show. In a project that lists one of these without a config of it in the working directory, such as one a build names with --config, cora init says it did not set the plugin up, and points you here.

vite.config.ts
import react from '@vitejs/plugin-react';
import sourceStamps from '@vispero/cora/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [react(), sourceStamps()],
});

It takes no options: the project’s CORA config says when it stamps. It runs ahead of Vite’s own JSX transform, and of plugins that set only enforce: 'pre', wherever it is listed. So it sees each file as it is on disk.

It stamps JSX in .jsx and .tsx files, and in .js files where your Vite config compiles JSX there: under the dev server, through an oxc option whose include and exclude take in .js files and whose lang is jsx, and in a build, through build.rolldownOptions.moduleTypes, set to read .js as jsx; before Vite 8, through an esbuild option whose include and exclude take in .js files and whose loader is jsx, in both. A .js file with no JSX passes through as it is.

It supports Vite 5.0.0 or later, the first that tells a plugin whether Vite is previewing a build. Each framework’s section below gives the oldest release of its compiler that the plugin reads.

Register it beside your framework’s own Vite plugin, as react() is above. It works with these frameworks and their plugins:

Framework Vite plugin
React @vitejs/plugin-react
Preact @preact/preset-vite
Solid vite-plugin-solid
React Router reactRouter() from @react-router/dev/vite, in framework mode, server-rendered pages included
Vue @vitejs/plugin-vue, or unplugin-vue’s Vite plugin
Svelte @sveltejs/vite-plugin-svelte, or SvelteKit’s sveltekit()
Nuxt Nuxt’s own Vite setup, through vite.plugins in nuxt.config
Astro Astro’s own Vite setup, through vite.plugins in astro.config
Lit none: Vite builds Lit’s modules itself, so the plugin is the only one listed

In a Vue project, the plugin stamps the elements of each single-file component’s template. It parses them with the compiler and the template options of the project’s Vue plugin, @vitejs/plugin-vue or unplugin-vue, so it reads each template as the project’s build does. It needs Vue 3.4.3 or later, the first whose parse takes isCustomElement and parses a template again when the options change, with @vitejs/plugin-vue 4.5.0 or later, or unplugin-vue 4.5.0 or later, each the first for Vite 5. A Vite config that lists neither, or a project on an older Vue, gets no stamps in its .vue files, and Vite prints a warning once.

  • A tag Vue takes as a component is never stamped, such as <SaveButton>, nor are <slot>, <component> and a <template> that holds a directive, such as v-if.
  • A tag written in kebab-case, such as <sl-button>, is a component to Vue unless the Vue plugin’s isCustomElement names it, as Vue needs it named to render it as itself. Once named, it is stamped:
vite.config.ts
import vue from '@vitejs/plugin-vue';
import sourceStamps from '@vispero/cora/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [
vue({ template: { compilerOptions: { isCustomElement: (tag) => tag.startsWith('sl-') } } }),
sourceStamps(),
],
});
  • A template in another language, such as Pug, or in a file of its own through src, gets no stamps.

In the JSX that Vue 3’s JSX compiler, @vue/babel-plugin-jsx, or its SWC port, swc-plugin-vue-jsx, builds, in .jsx and .tsx files, and in .js files where the bundler stamps JSX in them, the plugin stamps the tags Vue’s JSX compiles as HTML or SVG elements, such as <button> or <path>:

  • Any other name, such as <router-link> or a component registered as save-button, is a component to Vue, and gets no stamp. The element the component renders keeps its own stamp when it is a host element in your project’s source, or otherwise takes its nearest stamped ancestor’s location, as an element of a component from node_modules does.
  • A custom element gets no stamp in Vue’s JSX, even one that the Vue JSX compiler’s isCustomElement names: no Vue JSX plugin publishes that option, and CORA reads no Babel config. A finding on it takes its nearest stamped ancestor’s location. In a .vue template, the same element is stamped where CORA reads the isCustomElement of the Vue plugin or loader that builds it.
  • The plugin knows a build compiles Vue’s JSX from its Vue JSX plugin: @vitejs/plugin-vue-jsx under Vite, unplugin-vue-jsx under Vite, Rollup and Rolldown, and @rsbuild/plugin-vue-jsx under Rsbuild, for every environment or the one it is registered for. In a build that lists none of them, under any bundler but esbuild, it knows from your package.json listing @vue/babel-plugin-jsx, unplugin-vue-jsx, @vue/cli-plugin-babel, @vue/babel-preset-app or swc-plugin-vue-jsx: the nearest package.json from the bundler’s root, such as webpack’s context, upward. That covers webpack and Rspack, whose plugins it cannot see, and a build whose JSX Babel compiles with no Vue JSX plugin, such as through @rollup/plugin-babel. A change to which of them it lists takes effect when the bundler starts again. With swc-plugin-vue-jsx, which keeps its own lists of HTML and SVG elements, a tag that those lists and Vue’s do not share may get no stamp, and then takes its nearest stamped ancestor’s location. context defaults to the working directory, so in a workspace whose build runs from its root, such as webpack --config packages/app/webpack.config.js, set context to the app’s directory, or list the package in the package.json at the root. In a Vue JSX setup it can see neither way, such as a preset of your own that brings the compiler, or another compiler of Vue’s JSX, a component written as a lowercase or hyphenated tag is stamped, as in a React project, and a finding on the element it renders names the line that uses the component.
  • This then applies to all of the build’s JSX, even a file that the Vue JSX plugin’s include or exclude leaves to another plugin, such as React’s beside it: there a React file’s tags that are no HTML or SVG element, its custom elements among them, get no stamp.
  • Under esbuild, a build with unplugin-vue-jsx gets no JSX stamps, since esbuild loads each .jsx and .tsx file through one plugin, Vue’s. The plugin leaves them to it, wherever it is listed, so a Lit template in one of them gets no stamps either, and esbuild prints a warning as the build starts.
  • When a build lists a Vue JSX plugin but neither the plugin nor your project’s vue resolves from the bundler’s root, so the names of Vue’s elements cannot be loaded, the bundler prints a warning once and none of the build’s JSX is stamped. Install them in the project to fix it. Where only your package.json says the build compiles Vue’s JSX, and no @vue/shared can be loaded, the plugin stamps every lowercase or hyphenated tag instead, as in a React project, with no warning.

It needs @vue/babel-plugin-jsx 1.3.0 or later, the first that takes a tag as an element by Vue’s own lists of elements, which the plugin reads. An older one’s lists differ from Vue’s on a few rare tags: <slot>, <math>, <search>, <menuitem> and SVG’s font elements, among others, get no stamp, and SVG 2’s <feDropShadow>, <discard> and mesh elements are stamped though the older compiler takes them as components. It supports @vitejs/plugin-vue-jsx 3.1.0 or later, the first for Vite 5, unplugin-vue-jsx 0.6.0 or later, the first with an entry for Rolldown, and @rsbuild/plugin-vue-jsx 1.0.1 or later, the first for Rsbuild 1.

Nuxt configures Vite in nuxt.config, and reads no Vite config, so register the plugin in its vite.plugins, as cora init does:

nuxt.config.ts
import sourceStamps from '@vispero/cora/vite';
export default defineNuxtConfig({
vite: { plugins: [sourceStamps()] },
});

The plugin then stamps the project’s .vue files as in any Vue project, server-rendered pages and the client’s alike, and takes custom elements from vue.compilerOptions.isCustomElement, which Nuxt hands to its Vue plugin.

In a Svelte or SvelteKit project, the plugin stamps the elements of each component. It parses them with the project’s own Svelte, as Vite’s root finds it, before any preprocessor, such as vitePreprocess, has changed the file. It needs Svelte 5.0.0 or later, the first with the syntax tree it reads. A project with no such Svelte that Vite’s root can load gets no stamps in its .svelte files, and Vite prints a warning once.

  • A custom element, such as <sl-button>, is stamped, as Svelte renders it as itself.
  • An element Svelte’s parse does not take as a regular element is never stamped: a component, a <slot> that takes a parent’s content, the svelte: elements, <svelte:element> among them, and the <title> that <svelte:head> holds.
  • A file whose name holds { or } is named as it is written, as in every other file.
  • A component whose markup Svelte’s parse cannot read, written for a preprocessor, gets no stamps. Pug in a <template lang="pug"> parses: the <template> gets a stamp, and its Pug lines get none.

Astro configures Vite in astro.config, so register the plugin in its vite.plugins, as cora init does:

astro.config.ts
import sourceStamps from '@vispero/cora/vite';
import { defineConfig } from 'astro/config';
export default defineConfig({
vite: { plugins: [sourceStamps()] },
});

The plugin parses each .astro file’s template with Astro 7’s compiler, @astrojs/compiler-rs, which Astro 7 installs. An Astro 6 project adds it as a development dependency, such as with npm install -D @astrojs/compiler-rs. It needs @astrojs/compiler-rs 0.1.0 or later. A project with neither, or with an older compiler, gets no stamps in its .astro files, and Vite prints a warning once.

  • An element written with a lowercase name, a custom element included, is stamped, as Astro renders it as itself.
  • A component, Fragment, <slot>, <script> and <style> are not stamped: Astro fills a slot, bundles a script, which an attribute of the plugin’s would make it leave as written, and lifts a style’s rules out of the page’s markup, so it renders no element of its own.
  • A framework component an Astro page renders, such as a React or Vue island, is stamped in its own file, by the plugin’s transform for that framework’s files, where the project’s Astro integration for it builds them.
  • Pages written as .md, .mdx or .html files get no stamps: the plugin reads only .astro files of Astro’s own syntax.
  • Astro 7’s compiler refuses some markup that Astro 6’s own compiler takes, such as a list item that only the next one closes. On Astro 6, a file of that kind gets no stamps, and no warning.

In a Lit project, the plugin stamps the elements of each template tagged with the html, svg or mathml a file imports from lit, lit/html.js, lit-html, lit-element or lit-element/lit-element.js, or from lit/static-html.js or lit-html/static.js, in the project’s own .js, .mjs, .ts, .mts, .jsx and .tsx files, under each bundler plugin in the table above. It reads each template’s static parts as markup, as the browser reads a Lit template, so an element in a component’s shadow root and one in the light DOM are each located at their tag in the template. Vite needs no plugin of its own for Lit, so its config lists CORA’s alone:

vite.config.ts
import sourceStamps from '@vispero/cora/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [sourceStamps()],
});
  • A custom element, such as <sl-button>, is stamped, as Lit renders it as itself.
  • The stamp is a plain attribute in a template’s static text, just before the tag’s >, after its attributes and bindings. Expressions, such as ${this.label} or @click=${this.save}, are never changed, since their values are what your app renders.
  • An element whose tag name is an expression gets no stamp, such as <${tag}> or a tag built with literal from lit/static-html.js, since its tag is not in your file. The rest of its template is stamped.
  • An svg template’s elements are read as SVG, and a mathml template’s as MathML, as Lit reads them. A css template, a template tagged with any other function, and a plain template literal are left as they are.
  • In an html template, nothing inside a <textarea>, <title>, <style> or <script>, or inside a comment, is stamped, since the browser reads it as text.
  • A file that declares another html, such as a function parameter of that name, gets no stamps in the templates tagged with that name, since the plugin cannot tell which of the two each one means.
  • A class with decorators is read whether it uses the standard decorators or TypeScript’s experimentalDecorators.
  • Markup that is not in a template’s text gets no stamp, such as HTML rendered with unsafeHTML or written by unsafeStatic; a finding there takes its nearest stamped ancestor’s location.
  • A template tagged with an html that a module of your own re-exports from Lit, such as a design system’s, gets no stamps, since the plugin reads each file alone. Import the tag from Lit’s own modules in the files that hold your templates.
  • A template whose static text holds an escape other than \\, \`, \$, \{ or \}, such as \", \n or \x3c, gets no stamps: such an escape can move a tag’s end, or open a tag, in what Lit renders, where a stamp would change your page, and reading every escape as JavaScript does is not worth its cost for so rare a template.
  • A Lit template in a .vue, .svelte or .astro file’s script gets no stamps, since the plugin reads only that file’s own template there.

In a webpack project, register @vispero/cora/webpack among the config’s plugins:

webpack.config.js
import sourceStamps from '@vispero/cora/webpack';
export default {
mode: 'development',
// ...your loaders, such as babel-loader or swc-loader for JSX
plugins: [sourceStamps()],
};

In an Rspack project, register @vispero/cora/rspack the same way, among the config’s plugins:

rspack.config.mjs
import sourceStamps from '@vispero/cora/rspack';
export default {
mode: 'development',
// ...your loaders, such as Rspack's builtin:swc-loader for JSX
plugins: [sourceStamps()],
};

In an Rsbuild project, register @vispero/cora/rsbuild among the Rsbuild config’s plugins:

rsbuild.config.ts
import { defineConfig } from '@rsbuild/core';
import { pluginReact } from '@rsbuild/plugin-react';
import sourceStamps from '@vispero/cora/rsbuild';
export default defineConfig({
plugins: [pluginReact(), sourceStamps()],
});

Each adds CORA’s loader to the config’s rules once every plugin has applied, in Rsbuild to the Rspack config of each environment Rsbuild builds, after tools.rspack, where it runs before every loader the config’s rules list, enforce: 'pre' ones that other plugins add included, wherever CORA’s plugin is listed, so it sees each file as it is on disk, before your JSX compiler does. A request whose inline loaders turn the config’s off, such as one starting !!, gets no stamps. cora init edits a config that exports an object literal, a function that returns one, a name for one or a list of them, each through its bundler’s defineConfig too.

It supports webpack 5.61.0 or later, the first that builds on the Node releases CORA supports, Rspack 1.0.0 or later, and Rsbuild 1.0.1 or later, the first Rsbuild 1 on Rspack 1.

  • It stamps JSX in .jsx, .tsx and .js files, since webpack projects often write JSX in .js files, those written with Flow’s types included, as a Flow preset compiles them, and Lit templates in the script files that section names, .ts files included, before your TypeScript loader reads them. A .js file with neither JSX nor a Lit template passes through as it is, and so does one that parses neither as JavaScript with JSX nor with Flow’s types, with no stamps and no warning, since a .js file need not be JSX at all. JSX that Vue’s JSX compiler builds is stamped as Vue with JSX says. A persistent cache is kept apart by which Vue JSX package your package.json lists.
  • It stamps the templates of .vue files that vue-loader builds, under webpack and Rspack, and that Rsbuild’s Vue plugin builds with rspack-vue-loader, or with vue-loader before the plugin’s 1.2.0. It parses each with the compiler and the compilerOptions of the Vue loader that builds the file, so a custom element its isCustomElement names is stamped, as under Vite; under Rsbuild, give it through the Vue plugin’s vueLoaderOptions. It needs Vue 3.4.3 or later, with vue-loader 16.8.2 or later, the first that builds under Rspack too, or rspack-vue-loader 17.4.4 or later; a project on an older Vue, or whose Vue loader’s compiler CORA cannot load, gets no stamps in its .vue files, and the bundler prints a warning once. The limits under Vue on a Pug template and a <template src> hold here too.
  • It stamps the elements of .svelte files that svelte-loader builds, with your project’s own Svelte, 5.0.0 or later, as under Vite. It stamps no .astro file, since Astro builds with Vite.
  • A module you import as its text or its address is left as the file holds it, so a page that shows it shows no stamp: one whose query is raw, url or inline, or whose module type is not JavaScript, such as a .jsx file imported with with { type: 'text' }, which webpack and Rspack load as asset/source.
  • Left unset, the setting stamps in the development mode, and not in production or none. A webpack or Rspack config with no mode builds as production, so it carries no stamps unless the setting is true. A dev server or --watch stamps as its mode says; by default, rsbuild dev builds in development and rsbuild build in production, unless mode or NODE_ENV says otherwise.
  • A config that runs several compilers, such as a client and a server build, or Rsbuild’s environments, stamps each by its own mode.
  • It reads the setting for each context and bundler a process builds with, in development and not, from the project root it finds from that context, and reads it again once the CORA config or CORA_SOURCE_STAMPS changes, appears or goes, so a watch’s next build and a restarted dev server take the edit. Each module depends on each file a CORA config would be, the one there and, where the bundler offers it, the ones that are not, so a watch and a persistent cache build it again when the config changes, appears or is fixed, and the plugin adds CORA_SOURCE_STAMPS to a persistent cache’s version, so a build with it set and one without never share their modules. A persistent cache tracks nothing else the setting may come from: if your cora.config.ts takes sourceStamps from another environment variable or from a module it imports, clear the cache when that value changes, or set CORA_SOURCE_STAMPS instead. It takes a change to the Vue loader’s compilerOptions only where it builds again when the file that sets them changes, as for the loader’s own output: list that file, such as your bundler’s config, in the cache’s build dependencies.

In a Rollup project, register @vispero/cora/rollup among the config’s plugins:

rollup.config.mjs
import sourceStamps from '@vispero/cora/rollup';
export default {
input: 'src/main.jsx',
// ...and the plugin that compiles your JSX, before or after it
plugins: [sourceStamps()],
};

In a Rolldown project, register @vispero/cora/rolldown the same way:

rolldown.config.mjs
import sourceStamps from '@vispero/cora/rolldown';
export default {
input: 'src/main.jsx',
// Rolldown compiles JSX itself, after its plugins
plugins: [sourceStamps()],
};

cora init appends the plugin to the config’s plugins, as it does for every bundler.

It supports Rollup 2.78.0 or later, the first that takes a plugin’s hook written as an object, and Rolldown 1.0.0 or later, its first full release.

  • Rollup and Rolldown stamp JSX in .jsx and .tsx files, and in .js files, which Rollup’s jsx option, from 4.24.0, or a JSX plugin compiles in any module, and Rolldown where its moduleTypes option reads .js as jsx. A .js file with neither JSX nor a Lit template passes through as it is. Both stamp Lit templates in the script files that section names, and the templates of .vue files, on Vue 3.4.3 or later, and the elements of .svelte files, with your project’s own Svelte, 5.0.0 or later. Neither stamps .astro files, since Astro builds with Vite. Both stamp JSX that Vue’s JSX compiler builds as Vue with JSX says.
  • With unplugin-vue in the config, CORA parses a .vue file with its compiler and template options, as under Vite, so a custom element its isCustomElement names is stamped. With another Vue plugin, CORA parses with your project’s own Vue, where a custom element counts as a component and gets no stamp. As under webpack, a project on an older Vue, or with none CORA can find, gets no stamps in its .vue files, and the bundler prints a warning once. The limits under Vue on a Pug template and a <template src> hold either way.
  • Under Rolldown, a module its moduleTypes option loads as anything but js, jsx, ts or tsx, such as text, is left as the file holds it.
  • CORA’s plugin asks Rollup and Rolldown to run its transform first, with order: 'pre', so it reads each file as it is on disk, before the plugins that compile your JSX, TypeScript, Vue or Svelte, wherever it is listed. Rolldown compiles JSX itself, after its plugins. A plugin listed before CORA’s whose own transform also asks to run first, or one that changes a file as it loads it, runs before it: the files it changes carry no stamps, and the bundler prints a warning naming each, but a .js file it leaves holding neither a < nor an import from Lit, or a .mjs, .ts or .mts file holding no import from Lit, which gets none.
  • @rollup/plugin-typescript compiles each .ts and .tsx file as it loads it, before any plugin’s transform runs, so under it those files carry no stamps, and Rollup prints the warning that a plugin before CORA’s changed them, wherever CORA’s is listed. CORA’s plugin cannot load them first instead, since Rollup takes a file’s load from one plugin, and the TypeScript plugin would then compile nothing. Compile TypeScript with a plugin that transforms each file instead, such as rollup-plugin-esbuild, listed after CORA’s.
  • Left unset, the setting stamps in watch mode, --watch, and not in a build. Rollup reads it from the project root it finds from the working directory, and Rolldown from its cwd, as each build starts, a watch’s rebuilds included, and a watch builds again when the CORA config changes. Concurrent builds that share one plugin instance share one reading of the setting and of the build’s plugins: call the plugin’s function once for each build when their settings, working directories, or Vue or Vue JSX plugins differ.

In an esbuild project, which builds through a script, register @vispero/cora/esbuild among the build’s plugins:

build.mjs
import sourceStamps from '@vispero/cora/esbuild';
import * as esbuild from 'esbuild';
await esbuild.build({
entryPoints: ['src/main.jsx'],
bundle: true,
outdir: 'dist',
plugins: [sourceStamps()],
});

cora init does not register it, since an esbuild build is a script. It supports esbuild 0.19.7 or later, the first that gives a plugin an import’s attributes.

  • It stamps JSX in .jsx and .tsx files, and in .js files where your build’s loader option reads JSX there, such as loader: { '.js': 'jsx' }, Lit templates in the script files that section names, and no .vue, .svelte or .astro file: esbuild loads a .vue or .svelte file through one plugin, the framework’s own. A .js file with no JSX is left as it is. esbuild calls the plugin for every script file it loads, your dependencies’ included, and the plugin answers each it would not stamp from the file’s path alone, without reading it. A script file that the build’s loader option, for the longest extension it names that the file’s name ends with, such as .raw.jsx before .jsx, or an import’s type attribute, loads as anything but js, jsx, ts or tsx, such as text, is left to esbuild as it is. A file it stamps, it loads with the loader the option names, or the one its extension takes.
  • esbuild loads each file through the first plugin that gives its contents. CORA’s gives a file’s contents only when it stamps the file, and leaves every other file to the plugins after it. List it after any plugin of yours that loads a .jsx or .tsx file, a .js file holding JSX, or a script file holding Lit templates, so that plugin still runs; the files it loads carry no stamps, with no warning. With unplugin-vue-jsx, it leaves your .jsx and .tsx files to that plugin wherever it is listed, and warns once that it stamps none (Vue with JSX).
  • esbuild tells a plugin nothing of whether a build is for development, so the setting left unset stamps nothing. Set sourceStamps to true, or CORA_SOURCE_STAMPS=1 in the script that serves or builds for development. It reads the setting from the project root it finds from absWorkingDir, or the working directory, as each build starts, a rebuild included; each build reads its own, so builds that share a plugins array each take their own setting.

The Angular CLI’s builders take no plugin of a project’s own that reaches its templates: Angular’s compiler reads each template itself. Angular 22.1.4 or later writes each element’s place on its own, when the compiler option enableTemplateSourceLocations is on, and CORA’s scans read it. Turn it on for the builds you scan, the development configuration’s, as cora init does: a tsconfig beside the application’s own, which extends it,

tsconfig.cora.json
{
"extends": "./tsconfig.app.json",
"compilerOptions": { "rootDir": "." },
"angularCompilerOptions": { "enableTemplateSourceLocations": true }
}

and the development configuration of the application’s build target pointed at it:

angular.json
"configurations": {
"development": {
"optimization": false,
"tsConfig": "tsconfig.cora.json"
}
}

rootDir names the repository root from the tsconfig’s directory, such as "../.." for a workspace in apps/web, since Angular names each template’s file from it. Use rootDir, not rootDirs: under rootDirs, a template you edit under ng serve in a workspace below the repository root never reloads. TypeScript then reports TS6059 for a compiled source outside the repository. cora init writes both files for each application of the workspace, the tsconfig under a comment that marks it as CORA’s, so a later run rewrites it when a release changes what it writes, and leaves it once you edit it. A later cora init takes a tsconfig.cora.json you wrote by hand as set up only while it holds exactly the settings that release writes, however laid out; otherwise it leaves the workspace as it is and says so. Commit tsconfig.cora.json with angular.json: a checkout without it fails ng serve.

enableTemplateSourceLocations is an internal option of Angular’s: angular.dev does not list it, and a minor release of Angular may change or drop it. CORA’s own Angular fixture builds with the Angular its lockfile installs, so a change shows there first.

  • ng serve, and ng build --configuration development, then write data-ng-source-location on each element of the application’s templates, in a template’s own .html or .svg file and in an inline template written as one string alike: a component’s own element, such as <app-header>, included, and <ng-container> and <ng-template>, which render no element, left out. An element of an inline template built from an expression, such as a template literal with ${}, a constant or a concatenation, gets no location, since Angular names the component’s .ts file with offsets into the template’s evaluated text, which the file does not hold there. It gets no call site either: the scan cannot tell its location from one an edit made stale, and a stale location never falls back to an ancestor’s, which would name another component’s line. A scan reads a location only from a .html, .svg or .ts file in the repository, by its name and by the file a link leads it to, so a page cannot make a scan read a secret file, such as .env. An element whose location names a file of another kind is taken as one with no location, so it takes its nearest ancestor’s location as its call site, unless that one too names a file of another kind. A page can still name any .html, .svg or .ts file there, and a finding then shows the text from a < at the offset it names to the next >, wherever that file holds a tag-like run of text at that place. A production build keeps the application’s own tsconfig, and carries none.
  • sourceStamps and CORA_SOURCE_STAMPS do not switch Angular’s locations: the tsconfig a configuration names does. A production build keeps them off, since its configuration names the application’s own tsconfig, and pointing it at CORA’s would ship each element’s place to your users. To scan a production build at its source in CI, add a configuration that names CORA’s tsconfig alone, and build with it after production, which it overrides only there; the same caveat as the option applies:
angular.json
"configurations": {
"cora": { "tsConfig": "tsconfig.cora.json" }
}
Terminal window
ng build --configuration production,cora

Scan that build, and deploy one built without cora, which carries no locations.

  • cora init sets nothing up in a project whose Angular is older than 22.1.4, which has no such option, in one whose packages are not installed yet, where it cannot tell the version, in one installed through Yarn’s Plug’n’Play, since it reads the version from node_modules, which such an install does not have, or in one that has no angular.json in the working directory, such as an Nx workspace. On Plug’n’Play, set it up by hand as this section shows. It leaves the whole workspace to you, and names this page, when an application’s build target names no tsConfig or has no development configuration, when its tsconfig, or one it extends, sets rootDirs already, when its directory holds a tsconfig.cora.json of another’s, or when two applications with different tsconfigs share a directory; its notice names the application. Change what the notice names and run cora init again, which sets up the workspace once no application stops it.
  • Angular names a file in lower case when it builds on a file system that ignores case, as on macOS and Windows. A scan finds the file by its real name wherever it runs, unless two files of a directory differ only by case: then it reads the one whose name matches Angular’s exactly, and none where neither does. Give one of the two files another name.
  • Angular’s own compiler reads the templates, so a template that does not parse fails the build with Angular’s error, as it would without the option.

The project’s config says when the plugin stamps, through sourceStamps. Left unset, it stamps in development, as Choose your bundler gives for each bundler, and not in a build. true stamps every build, and false none. To stamp the builds a CI job scans, for example:

cora.config.ts
import { defineConfig } from '@vispero/cora/config';
export default defineConfig({
baseUrl: 'http://localhost:4173',
sourceStamps: true,
});
  • CORA_SOURCE_STAMPS replaces the config’s value for one build: CORA_SOURCE_STAMPS=1 npx vite build, or the same before any bundler’s build, builds a stamped app whatever the config says. npx cora info lists whether it is set.
  • Under Vite’s dev server, when it stamps, it stamps every page the server sends, the client’s and the server-rendered alike.
  • Under Vitest, which runs a project’s Vite plugins in mode test, it never stamps. So the markup a test renders, and its snapshots, carry no stamps.

The plugin reads the setting from the config in the project root, which it finds from the bundler’s root, such as Vite’s root or webpack’s context, as cora finds it from where it runs (Configuration). So it loads cora.config.ts in the bundler’s process. Under Vite, an edit to the setting takes effect when the dev server or the build starts again; under webpack’s family, at the next build once the config file or CORA_SOURCE_STAMPS has changed, though not once only a module the config imports has; under Rollup, Rolldown and esbuild, at the next build.

A config it cannot read never fails the build, and nor does a CORA_SOURCE_STAMPS that is not a switch. The bundler prints one warning that names the error’s code, once per build under Rollup, Rolldown and esbuild, which read the setting as each build starts, and the plugin stamps as if the setting were left out. The same config or variable fails cora scan until it is fixed.

  • It stamps the files Choose your bundler gives for each bundler, in the project’s repository, and none in node_modules or outside the repository.
  • A file that another plugin or loader has already changed gets no stamps, since their positions would not be the file’s. The bundler prints a warning that names the file, once. Under esbuild, which loads a file through one plugin, a file another plugin loads never reaches CORA’s, and no warning names it, but for the one warning of a build with unplugin-vue-jsx.

Each stamped element carries one value:

<main class="app" data-cora-source="1:src/App.tsx:12:5:12:27:YgYYvutAeZ2zM47gyndF"></main>

Its fields, separated by colons, are:

  1. the version of the value’s format, 1;
  2. the file, from the repository root, with forward slashes on every platform;
  3. the line and column where the element’s opening tag starts, at its <;
  4. the line and column just after the opening tag’s closing >;
  5. a digest of the file’s text, 20 characters, which tells whether the file has changed since the stamp was written.
  • Lines and columns count from 1. A column counts characters as JavaScript does, so a tab is one column and an emoji is two.
  • The repository root is the nearest directory, from the bundler’s root up, whose .git is a file, as in a worktree or a submodule, or a directory that holds HEAD. Outside a git repository, the repository root is the project root. When there is neither, the bundler prints a warning, and nothing is stamped.
  • The attribute is markup like any other data- attribute: it changes nothing the page does.

cora scan and cora-mcp’s scans read the stamp of each element a finding names. A finding whose element has a stamp that holds carries its place in the source, as location.source in the results file:

{
"file": "src/App.tsx",
"line": 12,
"column": 5,
"endLine": 12,
"endColumn": 27,
"openingTag": "<main className=\"app\">",
"resolution": "element"
}

The file and positions are the stamp’s, or for Angular’s attribute those of the opening tag it leads to in the file now. openingTag is the tag’s source text, with each run of whitespace written as one space. resolution says whose tag it is:

  • element: the element’s own, when it has a stamp of its own.
  • call-site: its nearest stamped ancestor’s, when it has none, such as an element that a component from node_modules renders, or one inside a shadow root. That is the closest element in the project’s source that encloses the element: the custom element that holds the shadow root, or the host element around your use of a third-party component, or on an Angular app, where a component’s own element carries a location, that component’s tag. The elements such a component renders share that location. A custom element in Vue’s JSX has no stamp, so the call site of an element in its shadow root is the closest stamped element around the custom element.

A stamp holds only while its file has the text the stamp was written from, which its digest tells. Angular’s attribute carries no digest: it holds while its file still has an opening tag of the element’s name where Angular found it, at the same offset, line and column. So an edit below an element leaves its location in place, and an edit above it that moves it, or one that renames its tag, drops it until the app is rebuilt; an edit within its tag that moves nothing keeps it, with the tag’s text as the file holds it now. Any edit that leaves an opening tag of the element’s name at that offset, line and column is taken for the element, such as two rows of equal length swapped, a tag of the same shape moved there, the element commented out by a <!-- written over its indentation, or the element commented out with an identical one written where it was. A scan finds the repository root as the plugin does, but from the project’s root, and reads each file as it is on disk now. So it runs in a checkout of the files the app was built from.

A finding carries no location:

  • when its element’s file has been edited since the page was built, until the app is rebuilt, or under the dev server until the page loads again after the server sent the edited file; for Angular’s attribute, when the edit moved or renamed the element’s tag;
  • when the file is missing;
  • when neither the element nor an ancestor in its document has a stamp;
  • when another release’s plugin built the page with stamps this release does not read, until the app is built again;
  • in cora-mcp started outside a project, which reads no files.

A finding with a source location also carries a source fingerprint, built from its rule, its file and its opening tag. Lines are not part of it, so an edit elsewhere in the file keeps it. A change to the opening tag, or a move of the file, gives a new one.

An issue is then the findings that share that fingerprint, rather than one element on one page:

  • An element of a shared component is one issue on every page it appears on. The issue’s ID in cora-mcp’s results is that fingerprint.
  • Two places where your code uses a third-party component, under host elements whose opening tags read the same in one file, are one issue too.
  • cora-mcp’s results give each issue its source location and callSites: the number of distinct call-site locations its findings are at. So an agent knows how many places a fix at the call site must reach.

When an app starts being built with stamps, its issues regroup, so their IDs change once. A baseline written from a build without stamps still accepts the same violations. See How a violation is matched.

In the SARIF log of the report directory, an issue with a source location is located at its file and lines, rather than at the repository root. So GitHub code scanning shows its alert on the line to fix.

Code scanning tracks an alert by the issue’s fingerprint. So the first upload from a stamped build closes each regrouped issue’s alert at the repository root, and opens one at its source line.