Skip to content

Instantly share code, notes, and snippets.

@bm-vs
Created April 19, 2024 11:29
Show Gist options
  • Save bm-vs/98709e6263638af50243278440dac270 to your computer and use it in GitHub Desktop.
Save bm-vs/98709e6263638af50243278440dac270 to your computer and use it in GitHub Desktop.
sanity.types post processing with fragment generation
import {readFileSync, writeFileSync} from 'fs';
import path from 'path';
type TsAlias = string;
type OriginalFragment = string;
type ClosureMap = Map<TsAlias, OriginalFragment>;
const main = () => {
console.info('Minimizing Sanity types...');
const inputFile = '../sanity/sanity.types.ts';
const outputFile = '../sanity/sanity.types.ts';
const {sanityTypes, sanityQueries} = readFile(inputFile);
const {minimizedQueries, closureMap} = parseQueries(sanityQueries);
const fragments = closureMapToTypes(closureMap);
const manualTypes = generateManualTypes();
const minimizedTypes = sanityTypes + minimizedQueries + fragments + manualTypes;
const finalTypes = globalOverrides(minimizedTypes);
writeFileSync(path.resolve(__dirname, outputFile), finalTypes);
};
// Reads files and returns two strings
// sanityTypes is the part of sanity.types that contains the types generated
// from the schema (should remain untouched)
// sanityQueries is the part of sanity.types that contains the types generated
// from the queries (should be minimized)
const readFile = (inputFile: string) => {
const sanityTypesFile = readFileSync(path.resolve(__dirname, inputFile), 'utf8');
const firstQueryIndex = sanityTypesFile.indexOf('// Query');
const sanityTypes = sanityTypesFile.slice(0, firstQueryIndex);
const sanityQueries = sanityTypesFile.slice(firstQueryIndex);
return {sanityTypes, sanityQueries};
};
// Iterates over the generated queries and dedups snippets of queries
// These snippets are identified by the _ts field
// If this field does not exist in a closure, the closure is returned as is
// If a closure has the _ptId field, that means it's a unresolved portable text field
// Since we never deal with unresolved portable text fields (because our special LiveQuery
// handles that), we can safely replace it with SanityRichPortableText that corresponds to
// the resolved portable text blocks
const parseQueries = (sanityQueries: string) => {
let cursor = 0;
const closureMap: ClosureMap = new Map();
// Parses a closure and returns a minimized version of it
const parseClosure = (queries: string) => {
let tsAlias: string | null = null;
let parsedClosure = '';
for (; cursor < queries.length; cursor++) {
// If the closure has a typegen alias, store it
if (queries.startsWith('_ts', cursor)) {
tsAlias =
queries
.slice(cursor)
.match(/_ts: ["'](.*)["']/)
?.at(1) ?? null;
parsedClosure += queries.at(cursor);
continue;
}
// If the closure has a _ptId field, replace it with SanityRichPortableText
if (queries.startsWith('_ptId', cursor)) {
cursor = queries.indexOf('};', cursor);
return 'SanityRichPortableText[] | null';
}
// If the line is the query the type refers to, skip it (since it can get super big)
if (queries.startsWith('// Query', cursor)) {
cursor = queries.indexOf('\n', cursor);
continue;
}
// If starting a closure, parse it
if (queries[cursor] === '{') {
cursor++;
const closure = parseClosure(queries);
parsedClosure += closure;
continue;
}
// If the closure is ending, and it doesn't have a type,
// return whatever is inside of it, without any modifications
if (queries[cursor] === '}' && !tsAlias) {
return `{${parsedClosure}}`;
}
// If the closure is ending, use the _ts field to create a type
if (queries[cursor] === '}' && tsAlias) {
if (!closureMap.has(tsAlias)) {
closureMap.set(tsAlias, parsedClosure);
}
return tsAlias;
}
parsedClosure += queries.at(cursor);
}
return parsedClosure;
};
const minimizedQueries = parseClosure(sanityQueries);
return {minimizedQueries, closureMap};
};
// Any closures that were found to have a _ts field should have types generated for them
const closureMapToTypes = (closureMap: ClosureMap) => {
return [...closureMap.entries()].reduce(
(acc, [name, query]) => `${acc}${overrideClosureType(name, query)}`,
'',
);
};
// Since the generated types are not always correct, this allows us to override fields within closures
// This mostly happens when the query contains some sort of exotic filter like:
// *[_type == 'guideStep'][^._id in articles[]._ref][0] (the _id part is the problem)
// *[_type == 'customerServiceCategory' && _id == ^.category._ref][0]
// Also hierarchy tree things
// If Sanity typegen screws up at the query level, add a _ts field to the closures that are problematic
// (check @/groq/queries/get-site-map-query)
const overrideClosureType = (name: string, query: string) => {
const overrides = {
SanityInternalReferenceguideArticle: [
{
original: 'step: null;',
replacement: `step: {
slug: Slug | null;
guide: {
slug: Slug | null;
} | null;
} | null;`,
},
],
SanityCustomerServiceArticleCards: [
{
original: 'articles: null;',
replacement: `articles: Array<{
_id: string;
_type: "customerServiceArticle";
slug: Slug | null;
title: string | null;
lead: string | null;
}> | null;`,
},
],
SanityDocumentGroupList: [
{
original: 'documentGroups: Array<null>;',
replacement: 'documentGroups: SanityDocumentGroup[] | null;',
},
],
SanityDocumentGroupHierarchy: [
{
original: 'tree: null;',
replacement: `
tree: {
_key: string;
parent: string | null;
value: {
reference: {
_id: string;
slug: Slug | null;
} | null;
} | null;
}[] | null;
`,
},
],
SanitySiteMap: [
{
original: 'tree: null;',
replacement: `
tree: {
_key: string;
parent: string | null;
value: {
reference: SanityInternalReference | null;
} | null;
}[] | null;
`,
},
],
} as const;
if (name in overrides) {
const replacements = overrides[name as keyof typeof overrides];
for (const {original, replacement} of replacements) {
query = query.replace(original, replacement);
}
}
return `\n\nexport type ${name} = {${query}};\n`;
};
// Global overrides that are applied to the final types
// Mostly used to override some types that aren't generated correctly yet
// For example: some functions aren't supported yet, like count()
const globalOverrides = (types: string) => {
const overrides = [
{
// All count functions in the queries are coallesced with 0,
// creating this very unique type that can be overriden
original: '0 | unknown',
replacement: 'number',
},
];
for (const {original, replacement} of overrides) {
types = types.replaceAll(original, replacement);
}
return types;
};
// Use this to generate types that get used in the sanity.types file itself
// If it doesn't get used in the sanity.types file, you can just create the type elsewhere
// But in some circumstances, like with PortableText and InternalReference,
// these types save a lot of repetition in the sanity.types file
const generateManualTypes = () => {
return `
export type SanityRichPortableText = NonNullable<NonNullable<DummyPortableTextQueryResult>["content"]>[number];
export type SanityInternalReference =
| SanityInternalReferencearticle
| SanityInternalReferencecontactFormPage
| SanityInternalReferencecustomerServiceArticle
| SanityInternalReferencecustomerServiceCategory
| SanityInternalReferencecustomerServiceHome
| SanityInternalReferencedocumentGroup
| SanityInternalReferencedoorType
| SanityInternalReferencefaq
| SanityInternalReferenceglassFunction
| SanityInternalReferenceglassOption
| SanityInternalReferenceguide
| SanityInternalReferenceguideArticle
| SanityInternalReferenceinspirationalStory
| SanityInternalReferencematerial
| SanityInternalReferencemuntinBarStyle
| SanityInternalReferencepage
| SanityInternalReferenceprofile
| SanityInternalReferenceuValue
| SanityInternalReferencevacancy
| SanityInternalReferencewindowType;
`;
};
main();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment