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
10 changes: 9 additions & 1 deletion packages/root-cms/cli/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {Command} from 'commander';
import {bgGreen, black} from 'kleur/colors';
import {generateTypes} from './generate-types.js';
import {initFirebase} from './init-firebase.js';
import {pruneTranslations} from './prune-translations.js';

class CliRunner {
private name: string;
Expand Down Expand Up @@ -37,8 +38,15 @@ class CliRunner {
'generates root-cms.d.ts from *.schema.ts files in the project'
)
.action(generateTypes);
program
.command('prune_translations <doc>')
.alias('prune-translations')
.description(
'removes unused translations tagged with the provided doc id'
)
.action(pruneTranslations);
await program.parseAsync(argv);
}
}

export {CliRunner, generateTypes, initFirebase};
export {CliRunner, generateTypes, initFirebase, pruneTranslations};
274 changes: 274 additions & 0 deletions packages/root-cms/cli/prune-translations.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,274 @@
import path from 'node:path';
import {fileURLToPath} from 'node:url';

import {loadRootConfig, viteSsrLoadModule} from '@blinkk/root/node';
import {FieldValue} from 'firebase-admin/firestore';

import {RootCMSClient} from '../core/client.js';
import type {Field, Schema} from '../core/schema.js';
import type {
RichTextBlock,
RichTextData,
RichTextListItem,
RichTextTableRow,
} from '../shared/richtext.js';

const __dirname = path.dirname(fileURLToPath(import.meta.url));

type ProjectModule = typeof import('../core/project.js');

type NormalizeStringFn = (str: string) => string;

export async function pruneTranslations(docId: string) {
if (!docId || !docId.includes('/')) {
throw new Error('doc id must be in the format "<collection>/<slug>".');
}

const [collectionId, ...slugParts] = docId.split('/');
const slug = slugParts.join('/');
if (!collectionId || !slug) {
throw new Error('doc id must include both collection and slug.');
}

const rootDir = process.cwd();
const rootConfig = await loadRootConfig(rootDir, {command: 'root-cms'});
const cmsClient = new RootCMSClient(rootConfig);
const normalizeString = cmsClient.normalizeString.bind(cmsClient);

const modulePath = path.resolve(__dirname, './project.js');
const project = (await viteSsrLoadModule(
rootConfig,
modulePath
)) as ProjectModule;
const collectionSchema = await project.getCollectionSchema(collectionId);
if (!collectionSchema) {
console.warn(`collection schema not found for ${collectionId}`);
return;
}

const docStrings = new Set<string>();
for (const mode of ['draft', 'published'] as const) {
const doc = await cmsClient.getDoc(collectionId, slug, {mode});
if (!doc) {
continue;
}
extractFields(
docStrings,
collectionSchema.fields,
doc.fields || {},
collectionSchema.types || {},
normalizeString
);
}

const taggedTranslations = await cmsClient.loadTranslations({tags: [docId]});
const unusedTranslations: Array<{hash: string; source: string}> = [];
Object.entries(taggedTranslations).forEach(([hash, translation]) => {
const source = normalizeString(translation.source);
if (!docStrings.has(source)) {
unusedTranslations.push({hash, source});
}
});

const taggedCount = Object.keys(taggedTranslations).length;
console.log(
`found ${taggedCount} translation(s) tagged with "${docId}".`
);
if (unusedTranslations.length === 0) {
console.log('no unused translations found.');
return;
}

console.log(`removing tag from ${unusedTranslations.length} translation(s)...`);
const translationsPath = `Projects/${cmsClient.projectId}/Translations`;
const batch = cmsClient.db.batch();
unusedTranslations.forEach(({hash}) => {
const ref = cmsClient.db.doc(`${translationsPath}/${hash}`);
batch.update(ref, {tags: FieldValue.arrayRemove(docId)});
});
await batch.commit();

console.log('removed tags from the following sources:');
unusedTranslations.forEach(({source}) => {
console.log(`- ${source}`);
});
}

function extractFields(
strings: Set<string>,
fields: Field[],
data: Record<string, any>,
types: Record<string, Schema> = {},
normalizeString: NormalizeStringFn
) {
fields.forEach((field) => {
if (!field.id) {
return;
}
const fieldValue = data[field.id];
extractField(strings, field, fieldValue, types, normalizeString);
});
}

function extractField(
strings: Set<string>,
field: Field,
fieldValue: any,
types: Record<string, Schema> = {},
normalizeString: NormalizeStringFn
) {
if (!fieldValue) {
return;
}

function addString(text: string) {
const str = normalizeString(text);
if (str) {
strings.add(str);
}
}

if (field.type === 'object') {
extractFields(strings, field.fields || [], fieldValue, types, normalizeString);
} else if (field.type === 'array') {
const arrayKeys = fieldValue._array || [];
for (const arrayKey of arrayKeys) {
extractField(
strings,
field.of,
fieldValue[arrayKey],
types,
normalizeString
);
}
Comment on lines +133 to +143

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Skip translations stored in array fields

Array traversal relies on fieldValue._array, but pruneTranslations loads docs via cmsClient.getDoc, which unmarshals arrays into plain arrays without the _array metadata. As a result, translatable strings inside any array field are never added to docStrings, so running prune_translations on docs that contain translated arrays will incorrectly treat those strings as unused and strip their tags from the translations collection.

Useful? React with 👍 / 👎.

} else if (field.type === 'string' || field.type === 'select') {
if (field.translate) {
addString(fieldValue);
}
} else if (field.type === 'image') {
if (field.translate && fieldValue && fieldValue.alt && field.alt !== false) {
addString(fieldValue.alt);
}
} else if (field.type === 'multiselect') {
if (field.translate && Array.isArray(fieldValue)) {
for (const value of fieldValue) {
addString(value);
}
}
} else if (field.type === 'oneof') {
const fieldTypes = field.types || [];
let fieldValueType: Schema | undefined;
if (typeof fieldTypes[0] === 'string') {
const typeId = fieldValue._type as string;
if ((fieldTypes as string[]).includes(typeId)) {
fieldValueType = types[typeId];
}
} else {
fieldValueType = (fieldTypes as any[]).find(
(item: any) => item.name === fieldValue._type
);
}
if (fieldValueType) {
extractFields(
strings,
fieldValueType.fields || [],
fieldValue,
types,
normalizeString
);
}
} else if (field.type === 'richtext') {
if (field.translate) {
extractRichTextStrings(strings, fieldValue, normalizeString);
}
}
}

function extractRichTextStrings(
strings: Set<string>,
data: RichTextData,
normalizeString: NormalizeStringFn
) {
const blocks = data?.blocks || [];
blocks.forEach((block) => {
extractBlockStrings(strings, block, normalizeString);
});
}

function extractBlockStrings(
strings: Set<string>,
block: RichTextBlock,
normalizeString: NormalizeStringFn
) {
if (!block?.type) {
return;
}

function addString(text?: string) {
if (!text) {
return;
}
const str = normalizeString(text);
if (str) {
strings.add(str);
}
}

function addComponentStrings(components?: Record<string, any>) {
if (!components) {
return;
}
Object.values(components).forEach((component) => {
collectComponentStrings(component);
});
}

function collectComponentStrings(value: any) {
if (typeof value === 'string') {
addString(value);
return;
}
if (Array.isArray(value)) {
value.forEach((item) => collectComponentStrings(item));
return;
}
if (isPlainObject(value)) {
Object.values(value).forEach((item) => collectComponentStrings(item));
}
}

function extractList(items?: RichTextListItem[]) {
if (!items) {
return;
}
items.forEach((item) => {
addString(item.content);
addComponentStrings(item.components);
extractList(item.items);
});
}

if (block.type === 'heading' || block.type === 'paragraph') {
addString(block.data?.text);
addComponentStrings(block.data?.components);
} else if (block.type === 'orderedList' || block.type === 'unorderedList') {
extractList(block.data?.items);
} else if (block.type === 'html') {
addString(block.data?.html);
} else if (block.type === 'table') {
const rows = block.data?.rows || [];
rows.forEach((row: RichTextTableRow) => {
const cells = row.cells || [];
cells.forEach((cell) => {
const cellBlocks = cell.blocks || [];
cellBlocks.forEach((cellBlock) => {
extractBlockStrings(strings, cellBlock, normalizeString);
});
});
});
}
}

function isPlainObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}