Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/constants/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ export const ERROR_MESSAGES = {
`Skipped global field "${uid}": ${reason}`,
SKIPPED_GLOBAL_FIELD_NO_SCHEMA: (uid: string, reason: string) =>
`Skipped global field "${uid}": ${reason}. Did you forget to include it?`,
RENAMED_BLOCK_INTERFACE: (from: string, to: string) =>
`Renamed modular block interface "${from}" to "${to}": that name is already used by another generated interface.`,
SKIPPED_REFERENCE: (reference: string, reason: string) =>
`Skipped reference to content type "${reference}": ${reason}`,

Expand Down
106 changes: 88 additions & 18 deletions src/generateTS/factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
throwNumericIdentifierValidationError,
} from "./shared/utils";
import { ERROR_MESSAGES } from "../constants";
import { defaultInterfaces } from "./stack/builtins";

export function hasPrefixedNaming(prefix: string | undefined): boolean {
return typeof prefix === "string" && prefix.trim().length > 0;
Expand All @@ -24,6 +25,55 @@ export function composePrefixedInterfaceName(
return trimmed + _.upperFirst(_.camelCase(uid));
}

/**
* Every interface/type name that stack/builtins.ts will actually emit for this run, read
* from that module rather than restated here — a hand-copied list silently drifts as
* builtins are added, and a name missing from it collides in the generated output with
* no warning.
*
* The emission flags must be passed through accurately rather than all-enabled. Reserving
* a name that is not emitted is not harmless: the block that wanted it gets renamed, and
* it also consumes the shared suffix counter, shifting the suffix of every later
* collision in the batch. Both would rename interfaces that compile today.
*
* The JSON RTE flag is passed as true deliberately. The only name it adds is the JSON
* rich-text node interface, which a UID can never produce: a name is the upper-cased
* camel case of its UID, and that cannot yield an all-caps acronym prefix. The two
* live-preview helper names are unreachable for the same reason, so reserving any of
* the three can never rename anything.
*/
function collectBuiltinInterfaceNames(
prefix: string,
systemFields: boolean,
isEditableTags: boolean,
includeReferencedEntry: boolean,
): string[] {
const declarations = defaultInterfaces(
prefix,
systemFields,
isEditableTags,
true,
includeReferencedEntry,
).join("\n");

// Deliberately an exec loop rather than matchAll: tsconfig targets es2017, and
// String.prototype.matchAll is es2020. It runs fine on Node, but it does not type-check.
const names: string[] = [];
const pattern = /(?:interface|type)\s+(\w+)/g;
let match: RegExpExecArray | null;
while ((match = pattern.exec(declarations)) !== null) {
names.push(match[1]);
}
return names;
}
export function interfaceNameForUid(uid: string, prefix: string): string {
const trimmed = typeof prefix === "string" ? prefix.trim() : "";
if (!trimmed && isNumericIdentifier(uid)) {
return `InvalidInterface_${uid}`;
}
return composePrefixedInterfaceName(uid, trimmed);
}

export type TSGenOptions = {
docgen: DocumentationGenerator;
naming?: {
Expand All @@ -33,6 +83,8 @@ export type TSGenOptions = {
isEditableTags?: boolean;
includeReferencedEntry?: boolean;
logger?: Logger;
/** Interface names already claimed by the batch — see generateTSFromContentTypes. */
reservedNames?: string[];
};

export type TSGenResult = {
Expand All @@ -59,10 +111,6 @@ type GlobalFieldCache = {
[prop: string]: { definition: string };
};

type ModularBlockCache = {
[prop: string]: string;
};

enum TypeFlags {
BuiltinJS = 1 << 0,
BuiltinCS = 1 << 1,
Expand Down Expand Up @@ -100,11 +148,9 @@ export default function (userOptions: TSGenOptions) {
const visitedGlobalFields = new Set<string>();
const visitedContentTypes = new Set<string>();
const cachedGlobalFields: GlobalFieldCache = {};
const cachedModularBlocks: ModularBlockCache = {};
const modularBlockInterfaces = new Set<string>();
const uniqueBlockInterfaces = new Set<string>();
const blockInterfacesKeyToName: { [key: string]: string } = {};
let counter = 1;
const skippedFields: Array<{ uid: string; path: string; reason: string }> =
[];
const skippedBlocks: Array<{ uid: string; path: string; reason: string }> =
Expand All @@ -115,6 +161,40 @@ export default function (userOptions: TSGenOptions) {
? options.naming.prefix.trim()
: "";

// Every interface name already claimed: the builtins, plus every top-level content
// type and global field in this batch (seeded by generateTSFromContentTypes, which
// knows the whole batch — the factory itself only ever sees one content type at a
// time). Top-level names are never reallocated; only derived block names are.
const usedInterfaceNames = new Set<string>([
...collectBuiltinInterfaceNames(
trimmedNamingPrefix,
Boolean(options.systemFields),
Boolean(options.isEditableTags),
Boolean(options.includeReferencedEntry),
),
...(options.reservedNames ?? []),
]);

// Shared across all block-name collisions, deliberately: this reproduces the numbering
// the generator has always produced, so no interface that compiles today is renamed.
// A per-name counter would be tidier but would turn an existing `Card2` into `Card1`.
let counter = 1;

function reserveInterfaceName(baseName: string): string {
let candidate = baseName;
while (usedInterfaceNames.has(candidate)) {
candidate = `${candidate}${counter}`;
counter++;
}
if (candidate !== baseName) {
// Every other name-mangling path in this file reports itself; a silent rename
// leaves the customer with "Cannot find name 'Form'" and nothing to explain it.
logger?.warn(ERROR_MESSAGES.RENAMED_BLOCK_INTERFACE(baseName, candidate));
}
usedInterfaceNames.add(candidate);
return candidate;
}

// Collect numeric identifier errors instead of throwing immediately
const numericIdentifierErrors: Array<{
uid: string;
Expand Down Expand Up @@ -180,13 +260,7 @@ export default function (userOptions: TSGenOptions) {
}

function name_type(uid: string) {
if (trimmedNamingPrefix) {
return composePrefixedInterfaceName(uid, trimmedNamingPrefix);
}
if (isNumericIdentifier(uid)) {
return `InvalidInterface_${uid}`;
}
return composePrefixedInterfaceName(uid, "");
return interfaceNameForUid(uid, trimmedNamingPrefix);
}

function define_interface(
Expand Down Expand Up @@ -488,10 +562,7 @@ export default function (userOptions: TSGenOptions) {

uniqueBlockInterfaces.add(modularBlockSignature);

while (cachedModularBlocks[modularBlockInterfaceName]) {
modularBlockInterfaceName = `${modularBlockInterfaceName}${counter}`;
counter++;
}
modularBlockInterfaceName = reserveInterfaceName(modularBlockInterfaceName);

const modularBlockInterfaceDefinition = [
`export interface ${modularBlockInterfaceName}${options.systemFields ? ` extends ${trimmedNamingPrefix}SystemFields` : ""} {`,
Expand All @@ -501,7 +572,6 @@ export default function (userOptions: TSGenOptions) {

// Store or track the generated block interface for later use
modularBlockInterfaces.add(modularBlockInterfaceDefinition);
cachedModularBlocks[modularBlockInterfaceName] = modularBlockSignature;
blockInterfacesKeyToName[modularBlockSignature] = modularBlockInterfaceName;

// Wrap with ModularBlocks type to add _metadata support only when systemFields is enabled
Expand Down
21 changes: 18 additions & 3 deletions src/generateTS/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { GenerateTS, GenerateTSFromContentTypes } from "../types";
import { DocumentationGenerator } from "./docgen/doc";
import JSDocumentationGenerator from "./docgen/jsdoc";
import NullDocumentationGenerator from "./docgen/nulldoc";
import tsgenFactory from "./factory";
import tsgenFactory, { interfaceNameForUid } from "./factory";
import { defaultInterfaces } from "./stack/builtins";
import { format } from "../format/index";
import { ContentType } from "../types/schema";
Expand Down Expand Up @@ -139,13 +139,28 @@ export const generateTSFromContentTypes = async ({
const globalFields = new Set();
const definitions = [];

// Normalise once, here, so that the builtins, the reserved names and the generated
// interfaces all agree on the prefix. They used to disagree: `defaultInterfaces` got
// the raw value while the factory trimmed it, so a prefix of `null` emitted
// `nullFile` and a prefix of " CS " emitted ` CS File` against a reserved
// `CSFile`.
const normalizedPrefix = (prefix ?? "").trim();

// Every top-level interface name in this batch, claimed before generation starts.
// Content types are visited one at a time, so without this the factory cannot know
// about a content type it has not reached yet (DX-10385).
const reservedNames = contentTypes.map((contentType) =>
interfaceNameForUid(contentType.uid, normalizedPrefix)
);

const tsgen = tsgenFactory({
docgen,
naming: { prefix },
naming: { prefix: normalizedPrefix },
systemFields,
isEditableTags,
includeReferencedEntry,
logger,
reservedNames,
});
for (const contentType of contentTypes) {
const tsgenResult = tsgen(contentType);
Expand All @@ -169,7 +184,7 @@ export const generateTSFromContentTypes = async ({
const output = await format(
[
defaultInterfaces(
prefix,
normalizedPrefix,
systemFields,
isEditableTags,
hasJsonField,
Expand Down
44 changes: 44 additions & 0 deletions tests/unit/tsgen/name-collisions.ct.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
const text = (uid) => ({ uid, data_type: "text", multiple: false });

// A content type whose modular-blocks field is named `file`, colliding with the
// built-in `File` interface.
const blockVsBuiltin = {
uid: "page",
title: "Page",
schema_type: "content_type",
schema: [
text("title"),
{
uid: "file",
data_type: "blocks",
multiple: true,
blocks: [{ uid: "hero", title: "Hero", schema: [text("label")] }],
},
],
};

// DX-10385: content type `form`, plus content type `form_basic` whose modular-blocks
// field is also UID'd `form`.
const formCT = {
uid: "form",
title: "Form",
schema_type: "content_type",
schema: [text("title"), text("heading")],
};

const formBasicCT = {
uid: "form_basic",
title: "Form Basic",
schema_type: "content_type",
schema: [
text("title"),
{
uid: "form",
data_type: "blocks",
multiple: true,
blocks: [{ uid: "heading", title: "Heading", schema: [text("label")] }],
},
],
};

module.exports = { blockVsBuiltin, formCT, formBasicCT };
Loading
Loading