Skip to content

Commit

Permalink
Add sanitizeFileName option (#4058)
Browse files Browse the repository at this point in the history
* add sanitizeFileNames option

* typo

* fixups

* fixup tests

* lint

* lint

* fixup output

* fixup types

* test

* fixup test

* pr feedback

* remove sanitize defaults

* fixup test

* fixups

* fixup emitFile binding

* fixup wiring

* hmm

* handle non output options case

* update fragment validation

* update tests

* fixup tests

* ts fix

* Add docs in various places

* Test custom sanitizer and sanitize asset names

* Add another test

Co-authored-by: Lukas Taegert-Atkinson <lukas.taegert-atkinson@tngtech.com>
Co-authored-by: Lukas Taegert-Atkinson <lukastaegert@users.noreply.github.com>
  • Loading branch information
3 people committed Apr 29, 2021
1 parent 8c59abe commit 81e0afc
Show file tree
Hide file tree
Showing 37 changed files with 158 additions and 101 deletions.
1 change: 1 addition & 0 deletions cli/help.md
Expand Up @@ -51,6 +51,7 @@ Basic options:
--preserveModules Preserve module structure
--preserveModulesRoot Put preserved modules under this path at root level
--preserveSymlinks Do not follow symlinks when resolving files
--no-sanitizeFileName Do not replace invalid characters in file names
--shimMissingExports Create shim variables for missing exports
--silent Don't print warnings
--sourcemapExcludeSources Do not include source code in source maps
Expand Down
2 changes: 2 additions & 0 deletions docs/01-command-line-reference.md
Expand Up @@ -95,6 +95,7 @@ export default { // can be an array (for multiple inputs)
namespaceToStringTag,
noConflict,
preferConst,
sanitizeFileName,
strict,
systemNullSetters
},
Expand Down Expand Up @@ -310,6 +311,7 @@ Many options have command line equivalents. In those cases, any arguments passed
--preserveModules Preserve module structure
--preserveModulesRoot Put preserved modules under this path at root level
--preserveSymlinks Do not follow symlinks when resolving files
--no-sanitizeFileName Do not replace invalid characters in file names
--shimMissingExports Create shim variables for missing exports
--silent Don't print warnings
--sourcemapExcludeSources Do not include source code in source maps
Expand Down
1 change: 1 addition & 0 deletions docs/02-javascript-api.md
Expand Up @@ -161,6 +161,7 @@ const outputOptions = {
namespaceToStringTag,
noConflict,
preferConst,
sanitizeFileName,
strict,
systemNullSetters
};
Expand Down
9 changes: 9 additions & 0 deletions docs/999-big-list-of-options.md
Expand Up @@ -1320,6 +1320,15 @@ Default: `false`

Generate `const` declarations for exports rather than `var` declarations.

#### output.sanitizeFileName
Type: `boolean | (string) => string`<br>
CLI: `--sanitizeFileName`/`no-sanitizeFileName`
Default: `true`

Set to `false` to disable all chunk name sanitizations (removal of `\0`, `?` and `*` characters).

Alternatively set to a function to allow custom chunk name sanitization.

#### output.strict
Type: `boolean`<br>
CLI: `--strict`/`--no-strict`<br>
Expand Down
2 changes: 1 addition & 1 deletion src/Bundle.ts
Expand Up @@ -43,7 +43,7 @@ export default class Bundle {
const outputBundle: OutputBundleWithPlaceholders = Object.create(null);
this.pluginDriver.setOutputBundle(
outputBundle,
this.outputOptions.assetFileNames,
this.outputOptions,
this.facadeChunkByModule
);
try {
Expand Down
7 changes: 3 additions & 4 deletions src/Chunk.ts
Expand Up @@ -56,7 +56,6 @@ import relativeId, { getAliasName } from './utils/relativeId';
import renderChunk from './utils/renderChunk';
import { RenderOptions } from './utils/renderHelpers';
import { makeUnique, renderNamePattern } from './utils/renderNamePattern';
import { sanitizeFileName } from './utils/sanitizeFileName';
import { timeEnd, timeStart } from './utils/timers';
import { MISSING_EXPORT_SHIM_VARIABLE } from './utils/variableNames';

Expand Down Expand Up @@ -437,7 +436,7 @@ export default class Chunk {
unsetOptions: Set<string>
): string {
const id = this.orderedModules[0].id;
const sanitizedId = sanitizeFileName(id);
const sanitizedId = this.outputOptions.sanitizeFileName(id);
let path: string;
if (isAbsolute(id)) {
const extension = extname(id);
Expand Down Expand Up @@ -501,7 +500,7 @@ export default class Chunk {
}

getChunkName(): string {
return this.name || (this.name = sanitizeFileName(this.getFallbackChunkName()));
return this.name || (this.name = this.outputOptions.sanitizeFileName(this.getFallbackChunkName()));
}

getExportNames(): string[] {
Expand Down Expand Up @@ -816,7 +815,7 @@ export default class Chunk {
if (fileName) {
this.fileName = fileName;
} else {
this.name = sanitizeFileName(name || getChunkNameFromModule(facadedModule));
this.name = this.outputOptions.sanitizeFileName(name || getChunkNameFromModule(facadedModule));
}
}

Expand Down
2 changes: 2 additions & 0 deletions src/rollup/types.d.ts
Expand Up @@ -649,6 +649,7 @@ export interface OutputOptions {
preferConst?: boolean;
preserveModules?: boolean;
preserveModulesRoot?: string;
sanitizeFileName?: boolean | ((fileName: string) => string);
sourcemap?: boolean | 'inline' | 'hidden';
sourcemapExcludeSources?: boolean;
sourcemapFile?: string;
Expand Down Expand Up @@ -693,6 +694,7 @@ export interface NormalizedOutputOptions {
preferConst: boolean;
preserveModules: boolean;
preserveModulesRoot: string | undefined;
sanitizeFileName: (fileName: string) => string;
sourcemap: boolean | 'inline' | 'hidden';
sourcemapExcludeSources: boolean;
sourcemapFile: string | undefined;
Expand Down
66 changes: 28 additions & 38 deletions src/utils/FileEmitter.ts
Expand Up @@ -5,8 +5,8 @@ import {
EmittedChunk,
FilePlaceholder,
NormalizedInputOptions,
NormalizedOutputOptions,
OutputBundleWithPlaceholders,
PreRenderedAsset,
WarningHandler
} from '../rollup/types';
import { BuildPhase } from './buildPhase';
Expand All @@ -25,25 +25,21 @@ import {
warnDeprecation
} from './error';
import { extname } from './path';
import { isPlainPathFragment } from './relativeId';
import { isPathFragment } from './relativeId';
import { makeUnique, renderNamePattern } from './renderNamePattern';

interface OutputSpecificFileData {
assetFileNames: string | ((assetInfo: PreRenderedAsset) => string);
bundle: OutputBundleWithPlaceholders;
}

function generateAssetFileName(
name: string | undefined,
source: string | Uint8Array,
output: OutputSpecificFileData
outputOptions: NormalizedOutputOptions,
bundle: OutputBundleWithPlaceholders
): string {
const emittedName = name || 'asset';
const emittedName = outputOptions.sanitizeFileName(name || 'asset');
return makeUnique(
renderNamePattern(
typeof output.assetFileNames === 'function'
? output.assetFileNames({ name, source, type: 'asset' })
: output.assetFileNames,
typeof outputOptions.assetFileNames === 'function'
? outputOptions.assetFileNames({ name, source, type: 'asset' })
: outputOptions.assetFileNames,
'output.assetFileNames',
{
hash() {
Expand All @@ -58,7 +54,7 @@ function generateAssetFileName(
name: () => emittedName.substr(0, emittedName.length - extname(emittedName).length)
}
),
output.bundle
bundle
);
}

Expand Down Expand Up @@ -110,14 +106,9 @@ function hasValidType(
);
}

function hasValidName(emittedFile: {
type: 'asset' | 'chunk';
[key: string]: unknown;
}): emittedFile is EmittedFile {
function hasValidName(emittedFile: { type: 'asset' | 'chunk'; [key: string]: unknown; }): emittedFile is EmittedFile {
const validatedName = emittedFile.fileName || emittedFile.name;
return (
!validatedName || (typeof validatedName === 'string' && isPlainPathFragment(validatedName))
);
return !validatedName || typeof validatedName === 'string' && !isPathFragment(validatedName);
}

function getValidSource(
Expand Down Expand Up @@ -155,9 +146,10 @@ function getChunkFileName(
}

export class FileEmitter {
private bundle: OutputBundleWithPlaceholders | null = null;
private facadeChunkByModule: Map<Module, Chunk> | null = null;
private filesByReferenceId: Map<string, ConsumedFile>;
private output: OutputSpecificFileData | null = null;
private outputOptions: NormalizedOutputOptions | null = null;

constructor(
private readonly graph: Graph,
Expand Down Expand Up @@ -189,7 +181,7 @@ export class FileEmitter {
if (!hasValidName(emittedFile)) {
return error(
errFailedValidation(
`The "fileName" or "name" properties of emitted files must be strings that are neither absolute nor relative paths and do not contain invalid characters, received "${
`The "fileName" or "name" properties of emitted files must be strings that are neither absolute nor relative paths, received "${
emittedFile.fileName || emittedFile.name
}".`
)
Expand Down Expand Up @@ -226,31 +218,29 @@ export class FileEmitter {
return error(errAssetSourceAlreadySet(consumedFile.name || referenceId));
}
const source = getValidSource(requestedSource, consumedFile, referenceId);
if (this.output) {
this.finalizeAsset(consumedFile, source, referenceId, this.output);
if (this.bundle) {
this.finalizeAsset(consumedFile, source, referenceId, this.bundle);
} else {
consumedFile.source = source;
}
};

public setOutputBundle = (
outputBundle: OutputBundleWithPlaceholders,
assetFileNames: string | ((assetInfo: PreRenderedAsset) => string),
outputOptions: NormalizedOutputOptions,
facadeChunkByModule: Map<Module, Chunk>
): void => {
this.output = {
assetFileNames,
bundle: outputBundle
};
this.outputOptions = outputOptions;
this.bundle = outputBundle;
this.facadeChunkByModule = facadeChunkByModule;
for (const emittedFile of this.filesByReferenceId.values()) {
if (emittedFile.fileName) {
reserveFileNameInBundle(emittedFile.fileName, this.output.bundle, this.options.onwarn);
reserveFileNameInBundle(emittedFile.fileName, this.bundle, this.options.onwarn);
}
}
for (const [referenceId, consumedFile] of this.filesByReferenceId.entries()) {
if (consumedFile.type === 'asset' && consumedFile.source !== undefined) {
this.finalizeAsset(consumedFile, consumedFile.source, referenceId, this.output);
this.finalizeAsset(consumedFile, consumedFile.source, referenceId, this.bundle);
}
}
};
Expand Down Expand Up @@ -285,12 +275,12 @@ export class FileEmitter {
consumedAsset,
emittedAsset.fileName || emittedAsset.name || emittedAsset.type
);
if (this.output) {
if (this.bundle) {
if (emittedAsset.fileName) {
reserveFileNameInBundle(emittedAsset.fileName, this.output.bundle, this.options.onwarn);
reserveFileNameInBundle(emittedAsset.fileName, this.bundle, this.options.onwarn);
}
if (source !== undefined) {
this.finalizeAsset(consumedAsset, source, referenceId, this.output);
this.finalizeAsset(consumedAsset, source, referenceId, this.bundle);
}
}
return referenceId;
Expand Down Expand Up @@ -328,18 +318,18 @@ export class FileEmitter {
consumedFile: ConsumedFile,
source: string | Uint8Array,
referenceId: string,
output: OutputSpecificFileData
bundle: OutputBundleWithPlaceholders
): void {
const fileName =
consumedFile.fileName ||
findExistingAssetFileNameWithSource(output.bundle, source) ||
generateAssetFileName(consumedFile.name, source, output);
findExistingAssetFileNameWithSource(bundle, source) ||
generateAssetFileName(consumedFile.name, source, this.outputOptions!, bundle);

// We must not modify the original assets to avoid interaction between outputs
const assetWithFileName = { ...consumedFile, source, fileName };
this.filesByReferenceId.set(referenceId, assetWithFileName);
const options = this.options;
output.bundle[fileName] = {
bundle[fileName] = {
fileName,
name: consumedFile.name,
get isAsset(): true {
Expand Down
2 changes: 1 addition & 1 deletion src/utils/PluginContext.ts
Expand Up @@ -103,7 +103,7 @@ export function getPluginContext(
true,
options
),
emitFile: fileEmitter.emitFile,
emitFile: fileEmitter.emitFile.bind(fileEmitter),
error(err): never {
return throwPluginError(err, plugin.name);
},
Expand Down
18 changes: 7 additions & 11 deletions src/utils/PluginDriver.ts
Expand Up @@ -7,14 +7,14 @@ import {
EmitFile,
FirstPluginHooks,
NormalizedInputOptions,
NormalizedOutputOptions,
OutputBundleWithPlaceholders,
OutputPluginHooks,
ParallelPluginHooks,
Plugin,
PluginContext,
PluginHooks,
PluginValueHooks,
PreRenderedAsset,
SequentialPluginHooks,
SerializablePluginCache,
SyncPluginHooks
Expand Down Expand Up @@ -74,7 +74,7 @@ export class PluginDriver {
public getFileName: (fileReferenceId: string) => string;
public setOutputBundle: (
outputBundle: OutputBundleWithPlaceholders,
assetFileNames: string | ((assetInfo: PreRenderedAsset) => string),
outputOptions: NormalizedOutputOptions,
facadeChunkByModule: Map<Module, Chunk>
) => void;

Expand All @@ -92,15 +92,11 @@ export class PluginDriver {
) {
warnDeprecatedHooks(userPlugins, options);
this.pluginCache = pluginCache;
this.fileEmitter = new FileEmitter(
graph,
options,
basePluginDriver && basePluginDriver.fileEmitter
);
this.emitFile = this.fileEmitter.emitFile;
this.getFileName = this.fileEmitter.getFileName;
this.finaliseAssets = this.fileEmitter.assertAssetsFinalized;
this.setOutputBundle = this.fileEmitter.setOutputBundle;
this.fileEmitter = new FileEmitter(graph, options, basePluginDriver && basePluginDriver.fileEmitter);
this.emitFile = this.fileEmitter.emitFile.bind(this.fileEmitter);
this.getFileName = this.fileEmitter.getFileName.bind(this.fileEmitter);
this.finaliseAssets = this.fileEmitter.assertAssetsFinalized.bind(this.fileEmitter);
this.setOutputBundle = this.fileEmitter.setOutputBundle.bind(this.fileEmitter);
this.plugins = userPlugins.concat(basePluginDriver ? basePluginDriver.plugins : []);
const existingPluginNames = new Set<string>();
for (const plugin of this.plugins) {
Expand Down
1 change: 1 addition & 0 deletions src/utils/options/mergeOptions.ts
Expand Up @@ -223,6 +223,7 @@ function mergeOutputOptions(
preferConst: getOption('preferConst'),
preserveModules: getOption('preserveModules'),
preserveModulesRoot: getOption('preserveModulesRoot'),
sanitizeFileName: getOption('sanitizeFileName'),
sourcemap: getOption('sourcemap'),
sourcemapExcludeSources: getOption('sourcemapExcludeSources'),
sourcemapFile: getOption('sourcemapFile'),
Expand Down
2 changes: 2 additions & 0 deletions src/utils/options/normalizeOutputOptions.ts
Expand Up @@ -14,6 +14,7 @@ import {
import { ensureArray } from '../ensureArray';
import { errInvalidExportOptionValue, error, warnDeprecation } from '../error';
import { resolve } from '../path';
import { sanitizeFileName as defaultSanitizeFileName } from '../sanitizeFileName';
import { GenericConfigObject, warnUnknownOptions } from './options';

export function normalizeOutputOptions(
Expand Down Expand Up @@ -66,6 +67,7 @@ export function normalizeOutputOptions(
preferConst: (config.preferConst as boolean | undefined) || false,
preserveModules,
preserveModulesRoot: getPreserveModulesRoot(config),
sanitizeFileName: (typeof config.sanitizeFileName === 'function' ? config.sanitizeFileName : config.sanitizeFileName === false ? (id) => id : defaultSanitizeFileName) as NormalizedOutputOptions['sanitizeFileName'],
sourcemap: (config.sourcemap as boolean | 'inline' | 'hidden' | undefined) || false,
sourcemapExcludeSources: (config.sourcemapExcludeSources as boolean | undefined) || false,
sourcemapFile: config.sourcemapFile as string | undefined,
Expand Down
12 changes: 3 additions & 9 deletions src/utils/relativeId.ts
@@ -1,5 +1,4 @@
import { basename, extname, isAbsolute, relative, resolve } from './path';
import { sanitizeFileName } from './sanitizeFileName';

export function getAliasName(id: string) {
const base = basename(id);
Expand All @@ -11,12 +10,7 @@ export default function relativeId(id: string) {
return relative(resolve(), id);
}

export function isPlainPathFragment(name: string) {
// not starting with "/", "./", "../"
return (
name[0] !== '/' &&
!(name[0] === '.' && (name[1] === '/' || name[1] === '.')) &&
sanitizeFileName(name) === name &&
!isAbsolute(name)
);
export function isPathFragment(name: string) {
// starting with "/", "./", "../", "C:/"
return name[0] === '/' || name[0] === '.' && (name[1] === '/' || name[1] === '.') || isAbsolute(name);
}

0 comments on commit 81e0afc

Please sign in to comment.