diff --git a/server/utils/cdn-builder.ts b/server/utils/cdn-builder.ts index d82d24a..c2693e6 100644 --- a/server/utils/cdn-builder.ts +++ b/server/utils/cdn-builder.ts @@ -199,7 +199,12 @@ export async function executeCDNBuild(options: BuildOptions): Promise = {} - try { - meta = JSON.parse(await git.readFile(metaPath, branch)) as Record - } - catch { /* no meta */ } + // Read this model's meta ONCE. A missing meta *file* means "no status + // info" → legacy include-all (shouldIncludeEntry(undefined) === true). + // A meta file that fails to PARSE is corruption — let it throw to the + // outer catch instead of silently publishing everything unfiltered. + const metaPath = resolveMetaPath(ctx, model, effectiveLocale === 'data' ? defaultLocale : locale) + let metaRaw: string | null = null + try { + metaRaw = await git.readFile(metaPath, branch) + } + catch { + // No meta file for this model/locale — legacy content without meta. + } + const metaParsed = metaRaw === null + ? null + : JSON.parse(metaRaw) as Record - // Filter entries by publication status + // Filter by publication status. `outputMeta` is what ships alongside + // the content (null → the model has no meta file, nothing to upload). + let outputMeta: Record | EntryMeta | null = null + + if (model.kind === 'collection') { + // Collection meta is an id-keyed map — filter per entry. + const metaMap = metaParsed ?? {} const filtered: Record = {} for (const [id, entry] of Object.entries(content as Record)) { - if (shouldIncludeEntry(meta[id])) { - filtered[id] = entry - } + if (shouldIncludeEntry(metaMap[id])) filtered[id] = entry } content = filtered + + if (metaParsed !== null) { + const filteredMeta: Record = {} + for (const [id, m] of Object.entries(metaMap)) { + if (shouldIncludeEntry(m)) filteredMeta[id] = m + } + outputMeta = filteredMeta + } + } + else { + // singleton / dictionary: MCP writes meta as a SINGLE object + // ({ status, source, updated_by }), not an id-map. One status gates + // the whole artifact — a draft/scheduled/expired unit must not be + // served at all. + const single = metaParsed as EntryMeta | null + if (!shouldIncludeEntry(single ?? undefined)) { + // Not published — skip content AND meta so the stale-object sweep + // garbage-collects any previously-published copy. + continue + } + outputMeta = single } // Safety net: rewrite any relative media paths that reached the @@ -329,30 +378,23 @@ export async function executeCDNBuild(options: BuildOptions): Promise - - // Only include published entries' meta - const filteredMeta: Record = {} - for (const [id, m] of Object.entries(metaData)) { - if (shouldIncludeEntry(m)) filteredMeta[id] = m - } - + // Upload meta alongside the content (skip when the model has no meta). + if (outputMeta !== null) { const metaOutput = `meta/${model.id}/${effectiveLocale === 'data' ? 'data' : locale}.json` - const metaStr = JSON.stringify(filteredMeta, null, 2) + const metaStr = JSON.stringify(outputMeta, null, 2) await cdn.putObject(projectId, metaOutput, metaStr, 'application/json') uploadedPaths.add(metaOutput) filesUploaded++ totalSizeBytes += Buffer.byteLength(metaStr) } - catch { /* no meta to upload */ } } } - catch { - // Content file doesn't exist for this locale — skip + catch (e) { + // A content/meta parse error, media-normalize failure, or upload error + // for THIS model+locale. Previously swallowed → published content + // silently vanished from the CDN. Surface it (best-effort per model, + // the build continues for the rest) instead of dropping it on the floor. + reportDataLossRisk(e, { op: 'cdn-build.model', projectId, modelId: model.id, locale }) } } } @@ -528,6 +570,14 @@ export async function executeCDNBuild(options: BuildOptions): Promise, ): Promise>> { @@ -576,7 +627,7 @@ async function buildDocumentModel( // Check meta try { - const metaPath = resolveMetaPath(ctx, model, locale, slug) + const metaPath = resolveMetaPath(ctx, model, metaLocale, slug) const metaRaw = JSON.parse(await git.readFile(metaPath, branch)) as EntryMeta if (!shouldIncludeEntry(metaRaw)) continue } diff --git a/server/utils/content-paths.ts b/server/utils/content-paths.ts index 2dbc591..f221351 100644 --- a/server/utils/content-paths.ts +++ b/server/utils/content-paths.ts @@ -23,13 +23,16 @@ export function resolveModelPath(ctx: PathContext, modelId: string): string { return prefixed(ctx.contentRoot, PATH_PATTERNS.model.replace('{modelId}', modelId)) } -export function resolveContentPath( +/** + * Content directory (content-root-relative) for a model. Mirrors MCP's + * `contentDir` (`@contentrain/mcp/core/ops/paths`): a `content_path` override + * moves files OUTSIDE `.contentrain/`, otherwise they live under the model id. + * The content_path validation is Studio-only hardening MCP's helper omits. + */ +function resolveContentDirForModel( ctx: PathContext, - model: Pick, - locale: string, - slug?: string, + model: Pick, ): string { - // Custom content_path override — files live OUTSIDE .contentrain/ if (model.content_path) { // Validate content_path — prevent path traversal and sensitive path access const normalized = model.content_path.replace(/\\/g, '/') @@ -42,50 +45,52 @@ export function resolveContentPath( if (sensitivePatterns.some(p => lowerNorm === p || lowerNorm.startsWith(`${p}/`))) { throw new Error(`Invalid content_path: "${model.content_path}" — targets a protected directory`) } - const basePath = prefixed(ctx.contentRoot, model.content_path) - if (model.kind === 'document') { - if (model.i18n && slug) return `${basePath}/${slug}/${locale}.md` - if (slug) return `${basePath}/${slug}.md` - return basePath - } - // JSON kinds with content_path override - if (!model.i18n) return `${basePath}/data.json` - return `${basePath}/${locale}.json` + return prefixed(ctx.contentRoot, model.content_path) } + return prefixed(ctx.contentRoot, `${CONTENTRAIN_DIR}/content/${model.domain}/${model.id}`) +} - // i18n: false → uses noLocale pattern (data.json) - if (!model.i18n && model.kind !== 'document') { - const pattern = PATH_PATTERNS.content.noLocale as string - const resolved = pattern - .replace('{domain}', model.domain) - .replace('{modelId}', model.id) - return prefixed(ctx.contentRoot, resolved) - } +/** + * Resolve the on-disk path for a content file. + * + * CRITICAL: honors `model.locale_strategy` — MUST stay byte-for-byte aligned + * with MCP's canonical `contentFilePath`/`documentFilePath` + * (`@contentrain/mcp/core/ops/paths`), which is what the write path (`planContentSave`) + * actually commits. Resolving with the wrong strategy reads a non-existent path + * → silent skip (missing content in the CDN build + brain cache). `i18n: false` + * always collapses to `data.json` / `{slug}.md` regardless of strategy. + * + * With no `slug` for a document kind, returns the model's content directory + * (callers use it for `listDirectory`). + */ +export function resolveContentPath( + ctx: PathContext, + model: Pick, + locale: string, + slug?: string, +): string { + const dir = resolveContentDirForModel(ctx, model) + const strategy = model.locale_strategy ?? 'file' - // Standard documents live UNDER the model id — - // `.contentrain/content/{domain}/{modelId}/{slug}/{locale}.md` (i18n) or - // `.../{slug}.md` — matching how `@contentrain/mcp` planContentSave writes - // them. `PATH_PATTERNS.content.document` omits `{modelId}`, so resolving via - // the generic pattern below produced a path that doesn't exist on disk (a - // 404 on read). Build the model-id path directly. if (model.kind === 'document') { - const base = `${CONTENTRAIN_DIR}/content/${model.domain}/${model.id}` - if (model.i18n && slug) return prefixed(ctx.contentRoot, `${base}/${slug}/${locale}.md`) - if (slug) return prefixed(ctx.contentRoot, `${base}/${slug}.md`) - return prefixed(ctx.contentRoot, base) + if (!slug) return dir + if (!model.i18n) return `${dir}/${slug}.md` + switch (strategy) { + case 'suffix': return `${dir}/${slug}.${locale}.md` + case 'directory': return `${dir}/${locale}/${slug}.md` + case 'none': return `${dir}/${slug}.md` + default: return `${dir}/${slug}/${locale}.md` + } } - // Standard path from PATH_PATTERNS - const pattern = PATH_PATTERNS.content[model.kind as keyof typeof PATH_PATTERNS.content] - ?? PATH_PATTERNS.content.collection - - const resolved = (pattern as string) - .replace('{domain}', model.domain) - .replace('{modelId}', model.id) - .replace('{locale}', locale) - .replace('{slug}', slug ?? '') - - return prefixed(ctx.contentRoot, resolved) + // JSON kinds: collection, singleton, dictionary + if (!model.i18n) return `${dir}/data.json` + switch (strategy) { + case 'suffix': return `${dir}/${model.id}.${locale}.json` + case 'directory': return `${dir}/${locale}/${model.id}.json` + case 'none': return `${dir}/${model.id}.json` + default: return `${dir}/${locale}.json` + } } export function resolveMetaPath( diff --git a/tests/unit/cdn-builder.test.ts b/tests/unit/cdn-builder.test.ts index eb66735..563026e 100644 --- a/tests/unit/cdn-builder.test.ts +++ b/tests/unit/cdn-builder.test.ts @@ -3,6 +3,7 @@ import type { ModelDefinition } from '@contentrain/types' import type { GitProvider } from '../../server/providers/git' import type { CDNObject, CDNProvider } from '../../server/providers/cdn' import { executeCDNBuild, getAffectedModels } from '../../server/utils/cdn-builder' +import { reportDataLossRisk } from '../../server/utils/alert' import { resolveConfigPath, resolveContentPath, @@ -12,6 +13,10 @@ import { } from '../../server/utils/content-paths' import { rewriteMediaUrl, toDeliveryUrl } from '../../server/utils/media-url' +// Spy on the data-loss reporter so we can assert per-model build failures are +// surfaced (not silently swallowed) without pulling in Sentry. +vi.mock('../../server/utils/alert', () => ({ reportDataLossRisk: vi.fn() })) + function createGitProvider(files: Record): GitProvider { const normalize = (path: string) => path.replace(/^\/+/, '') @@ -93,6 +98,7 @@ function createCDNProvider() { describe('cdn builder', () => { beforeEach(() => { + vi.mocked(reportDataLossRisk).mockClear() vi.stubGlobal('resolveConfigPath', resolveConfigPath) vi.stubGlobal('resolveModelPath', resolveModelPath) vi.stubGlobal('resolveModelsDir', resolveModelsDir) @@ -478,6 +484,220 @@ describe('cdn builder', () => { expect(bundle.paths['content/team/en.json']).toEqual({ m1: { name: 'Existing member' } }) }) + // Regression: for i18n:false content, meta lives under the DEFAULT locale + // (MCP's contract), which is NOT necessarily supported[0]. The builder used + // `locales[0]` (= supported[0]) to resolve the meta path, so when + // default ∉ supported[0] (here default=tr, supported=[en,tr]) it read a + // non-existent meta file → the meta filter went blind: drafts leaked into the + // published artifact and the meta output stayed empty. Must read meta/{model}/tr.json. + it('resolves non-i18n collection meta under the default locale, not supported[0]', async () => { + const files = { + '.contentrain/config.json': JSON.stringify({ + stack: 'nuxt', + locales: { default: 'tr', supported: ['en', 'tr'] }, + domains: ['marketing'], + }), + '.contentrain/models/sponsors.json': JSON.stringify({ + id: 'sponsors', + name: 'Sponsors', + kind: 'collection', + domain: 'marketing', + i18n: false, + fields: {}, + }), + '.contentrain/content/marketing/sponsors/data.json': JSON.stringify({ + live: { name: 'Live sponsor' }, + hidden: { name: 'Draft sponsor' }, + }), + // Meta is written under the DEFAULT locale (tr), not supported[0] (en). + '.contentrain/meta/sponsors/tr.json': JSON.stringify({ + live: { status: 'published' }, + hidden: { status: 'draft' }, + }), + } + const normalize = (p: string) => p.replace(/^\/+/, '').replace(/\/$/, '') + const git = { + ...createGitProvider(files), + listDirectory: vi.fn(async (p: string) => { + if (normalize(p) === '.contentrain/models') return ['sponsors.json'] + return [] + }), + } as unknown as GitProvider + const { provider, objects } = createCDNProvider() + + const result = await executeCDNBuild({ + projectId: 'proj', + buildId: 'b', + git, + cdn: provider, + contentRoot: '', + commitSha: 's', + branch: 'main', + fullRebuild: true, + }) + + expect(result.error).toBeUndefined() + + // The draft must be filtered out (meta was actually read) — pre-fix this + // leaked because the meta lookup missed and defaulted to include-all. + const content = JSON.parse(objects.get('proj:content/sponsors/data.json') ?? 'null') + expect(content).toEqual({ live: { name: 'Live sponsor' } }) + + // And the filtered meta must be published under the `data` artifact — + // pre-fix the meta read threw, so no meta object was uploaded at all. + const meta = JSON.parse(objects.get('proj:meta/sponsors/data.json') ?? 'null') + expect(meta).toEqual({ live: { status: 'published' } }) + }) + + // Regression (#3): singleton/dictionary content is a single unit gated by ONE + // meta object ({ status, ... }), NOT an id-keyed map. A draft/unpublished unit + // must not reach the CDN. Previously only `collection` was status-filtered, so + // draft singletons/dictionaries leaked in full. + it('hides a draft singleton (content + meta) from the CDN', async () => { + const files = { + '.contentrain/config.json': JSON.stringify({ + stack: 'nuxt', + locales: { default: 'en', supported: ['en'] }, + domains: ['marketing'], + }), + '.contentrain/models/settings.json': JSON.stringify({ + id: 'settings', name: 'Settings', kind: 'singleton', domain: 'marketing', i18n: false, fields: {}, + }), + '.contentrain/content/marketing/settings/data.json': JSON.stringify({ title: 'Unpublished draft' }), + // Singleton meta is a SINGLE object under the default locale. + '.contentrain/meta/settings/en.json': JSON.stringify({ status: 'draft' }), + } + const normalize = (p: string) => p.replace(/^\/+/, '').replace(/\/$/, '') + const git = { + ...createGitProvider(files), + listDirectory: vi.fn(async (p: string) => (normalize(p) === '.contentrain/models' ? ['settings.json'] : [])), + } as unknown as GitProvider + const { provider, objects } = createCDNProvider() + + const result = await executeCDNBuild({ + projectId: 'proj', buildId: 'b', git, cdn: provider, contentRoot: '', commitSha: 's', branch: 'main', fullRebuild: true, + }) + + expect(result.error).toBeUndefined() + // Draft unit → neither content nor meta artifact is published. + expect(objects.has('proj:content/settings/data.json')).toBe(false) + expect(objects.has('proj:meta/settings/data.json')).toBe(false) + }) + + it('publishes a published singleton with its single meta object verbatim', async () => { + const files = { + '.contentrain/config.json': JSON.stringify({ + stack: 'nuxt', locales: { default: 'en', supported: ['en'] }, domains: ['marketing'], + }), + '.contentrain/models/settings.json': JSON.stringify({ + id: 'settings', name: 'Settings', kind: 'singleton', domain: 'marketing', i18n: false, fields: {}, + }), + '.contentrain/content/marketing/settings/data.json': JSON.stringify({ title: 'Live' }), + '.contentrain/meta/settings/en.json': JSON.stringify({ status: 'published', source: 'human' }), + } + const normalize = (p: string) => p.replace(/^\/+/, '').replace(/\/$/, '') + const git = { + ...createGitProvider(files), + listDirectory: vi.fn(async (p: string) => (normalize(p) === '.contentrain/models' ? ['settings.json'] : [])), + } as unknown as GitProvider + const { provider, objects } = createCDNProvider() + + const result = await executeCDNBuild({ + projectId: 'proj', buildId: 'b', git, cdn: provider, contentRoot: '', commitSha: 's', branch: 'main', fullRebuild: true, + }) + + expect(result.error).toBeUndefined() + expect(JSON.parse(objects.get('proj:content/settings/data.json') ?? 'null')).toEqual({ title: 'Live' }) + // Meta ships as the single object as-is (not wrapped in an id-map). + expect(JSON.parse(objects.get('proj:meta/settings/data.json') ?? 'null')).toEqual({ status: 'published', source: 'human' }) + }) + + it('publishes a singleton with no meta file (legacy content without status)', async () => { + const files = { + '.contentrain/config.json': JSON.stringify({ + stack: 'nuxt', locales: { default: 'en', supported: ['en'] }, domains: ['marketing'], + }), + '.contentrain/models/settings.json': JSON.stringify({ + id: 'settings', name: 'Settings', kind: 'singleton', domain: 'marketing', i18n: false, fields: {}, + }), + '.contentrain/content/marketing/settings/data.json': JSON.stringify({ title: 'Legacy' }), + // no meta file at all + } + const normalize = (p: string) => p.replace(/^\/+/, '').replace(/\/$/, '') + const git = { + ...createGitProvider(files), + listDirectory: vi.fn(async (p: string) => (normalize(p) === '.contentrain/models' ? ['settings.json'] : [])), + } as unknown as GitProvider + const { provider, objects } = createCDNProvider() + + const result = await executeCDNBuild({ + projectId: 'proj', buildId: 'b', git, cdn: provider, contentRoot: '', commitSha: 's', branch: 'main', fullRebuild: true, + }) + + expect(result.error).toBeUndefined() + expect(JSON.parse(objects.get('proj:content/settings/data.json') ?? 'null')).toEqual({ title: 'Legacy' }) + // No meta file existed → no meta artifact uploaded. + expect(objects.has('proj:meta/settings/data.json')).toBe(false) + }) + + // Regression (#2): a corrupt meta file must SURFACE (data-loss alert), not be + // silently swallowed into an unfiltered publish. Content-file absence stays a + // silent skip; only genuine errors are reported. + it('surfaces a corrupt meta file instead of silently over-publishing', async () => { + const files = { + '.contentrain/config.json': JSON.stringify({ + stack: 'nuxt', locales: { default: 'en', supported: ['en'] }, domains: ['marketing'], + }), + '.contentrain/models/faq.json': JSON.stringify({ + id: 'faq', name: 'FAQ', kind: 'collection', domain: 'marketing', i18n: true, fields: {}, + }), + '.contentrain/content/marketing/faq/en.json': JSON.stringify({ a1: { q: 'Q' } }), + // Corrupt meta — not valid JSON. + '.contentrain/meta/faq/en.json': '{ not valid json', + } + const { provider, objects } = createCDNProvider() + const git = createGitProvider(files) + + const result = await executeCDNBuild({ + projectId: 'proj', buildId: 'b', git, cdn: provider, contentRoot: '', commitSha: 's', branch: 'main', fullRebuild: true, + }) + + // Build itself completes (best-effort), but the failure is reported... + expect(result.error).toBeUndefined() + expect(vi.mocked(reportDataLossRisk)).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ op: 'cdn-build.model', projectId: 'proj', modelId: 'faq' }), + ) + // ...and the model's content was NOT published unfiltered (parse threw first). + expect(objects.has('proj:content/faq/en.json')).toBe(false) + }) + + it('silently skips a locale whose content file is absent (no data-loss alert)', async () => { + const files = { + '.contentrain/config.json': JSON.stringify({ + stack: 'nuxt', locales: { default: 'en', supported: ['en', 'tr'] }, domains: ['marketing'], + }), + '.contentrain/models/faq.json': JSON.stringify({ + id: 'faq', name: 'FAQ', kind: 'collection', domain: 'marketing', i18n: true, fields: {}, + }), + // Only the EN content exists; TR is genuinely absent (expected skip). + '.contentrain/content/marketing/faq/en.json': JSON.stringify({ a1: { q: 'Q' } }), + '.contentrain/meta/faq/en.json': JSON.stringify({ a1: { status: 'published' } }), + } + const git = createGitProvider(files) + const { provider, objects } = createCDNProvider() + + const result = await executeCDNBuild({ + projectId: 'proj', buildId: 'b', git, cdn: provider, contentRoot: '', commitSha: 's', branch: 'main', fullRebuild: true, + }) + + expect(result.error).toBeUndefined() + // EN published, TR absent — and absence is NOT reported as a data-loss risk. + expect(objects.has('proj:content/faq/en.json')).toBe(true) + expect(objects.has('proj:content/faq/tr.json')).toBe(false) + expect(vi.mocked(reportDataLossRisk)).not.toHaveBeenCalled() + }) + it('includes non-i18n bodies in every locale bundle', async () => { const files = { '.contentrain/config.json': JSON.stringify({ diff --git a/tests/unit/content-paths.test.ts b/tests/unit/content-paths.test.ts index 08577c4..266003b 100644 --- a/tests/unit/content-paths.test.ts +++ b/tests/unit/content-paths.test.ts @@ -52,6 +52,54 @@ describe('content path resolution', () => { expect(resolveContentPath(nestedCtx, documentModel, 'en')).toBe('apps/web/docs/content') }) + // Regression: resolveContentPath MUST honor model.locale_strategy, staying + // byte-for-byte aligned with MCP's canonical contentFilePath/documentFilePath + // (that's what planContentSave actually commits). A wrong strategy reads a + // non-existent path → silent skip in the CDN build + brain cache. + it('honors every locale_strategy for i18n JSON kinds', () => { + const base = { id: 'faq', kind: 'collection', domain: 'marketing', i18n: true, content_path: undefined } + + // file (default) — one dir per model, one file per locale + expect(resolveContentPath(rootCtx, base, 'en')).toBe('.contentrain/content/marketing/faq/en.json') + expect(resolveContentPath(rootCtx, { ...base, locale_strategy: 'file' }, 'en')).toBe('.contentrain/content/marketing/faq/en.json') + // suffix — {modelId}.{locale}.json in the model dir + expect(resolveContentPath(rootCtx, { ...base, locale_strategy: 'suffix' }, 'en')).toBe('.contentrain/content/marketing/faq/faq.en.json') + // directory — {locale}/{modelId}.json + expect(resolveContentPath(rootCtx, { ...base, locale_strategy: 'directory' }, 'en')).toBe('.contentrain/content/marketing/faq/en/faq.json') + // none — single {modelId}.json, locale not encoded + expect(resolveContentPath(rootCtx, { ...base, locale_strategy: 'none' }, 'en')).toBe('.contentrain/content/marketing/faq/faq.json') + }) + + it('honors every locale_strategy for i18n document kinds', () => { + const base = { id: 'docs', kind: 'document', domain: 'marketing', i18n: true, content_path: undefined } + + expect(resolveContentPath(rootCtx, base, 'en', 'intro')).toBe('.contentrain/content/marketing/docs/intro/en.md') + expect(resolveContentPath(rootCtx, { ...base, locale_strategy: 'suffix' }, 'en', 'intro')).toBe('.contentrain/content/marketing/docs/intro.en.md') + expect(resolveContentPath(rootCtx, { ...base, locale_strategy: 'directory' }, 'en', 'intro')).toBe('.contentrain/content/marketing/docs/en/intro.md') + expect(resolveContentPath(rootCtx, { ...base, locale_strategy: 'none' }, 'en', 'intro')).toBe('.contentrain/content/marketing/docs/intro.md') + }) + + it('ignores locale_strategy for non-i18n models (always data.json / flat .md)', () => { + const json = { id: 'nav', kind: 'singleton', domain: 'marketing', i18n: false, content_path: undefined } + const doc = { id: 'docs', kind: 'document', domain: 'marketing', i18n: false, content_path: undefined } + for (const strategy of ['file', 'suffix', 'directory', 'none'] as const) { + expect(resolveContentPath(rootCtx, { ...json, locale_strategy: strategy }, 'en')).toBe('.contentrain/content/marketing/nav/data.json') + expect(resolveContentPath(rootCtx, { ...doc, locale_strategy: strategy }, 'en', 'intro')).toBe('.contentrain/content/marketing/docs/intro.md') + } + }) + + it('applies locale_strategy under a content_path override too', () => { + const model = { id: 'faq', kind: 'collection', domain: 'marketing', i18n: true, content_path: 'content/faq', locale_strategy: 'suffix' as const } + expect(resolveContentPath(rootCtx, model, 'tr')).toBe('content/faq/faq.tr.json') + }) + + it('still rejects path-traversal and protected content_path targets', () => { + const traversal = { id: 'x', kind: 'collection', domain: 'm', i18n: true, content_path: '../secrets' } + const protectedPath = { id: 'x', kind: 'collection', domain: 'm', i18n: true, content_path: '.contentrain/models' } + expect(() => resolveContentPath(rootCtx, traversal, 'en')).toThrow(/path traversal/) + expect(() => resolveContentPath(rootCtx, protectedPath, 'en')).toThrow(/protected directory/) + }) + it('normalizes content roots for monorepo and root projects', () => { expect(normalizeContentRoot('/')).toBe('') expect(normalizeContentRoot('')).toBe('')