Skip to content

Commit

Permalink
Merge pull request #8 from HiDeoo/hd-json-locales-config
Browse files Browse the repository at this point in the history
  • Loading branch information
HiDeoo committed Jan 4, 2024
2 parents 6b2ba79 + 88ee63f commit 5f7fec9
Show file tree
Hide file tree
Showing 4 changed files with 236 additions and 33 deletions.
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ All notable changes to this project will be documented in this file.

## Unreleased

### 🚀 Features

- Support reading locales configuration from an imported relative JSON file.

### 🐞 Bug Fixes

- Fix path issue when no root locale is defined.
Expand Down
70 changes: 65 additions & 5 deletions src/libs/ast.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,19 @@ import {
isVariableDeclaration,
isExportNamedDeclaration,
type Identifier,
isImportDeclaration,
isImportDefaultSpecifier,
isExportDefaultDeclaration,
type ImportDeclaration,
} from '@babel/types'

import type { Locale, LocalesConfig } from './config'

export function getStarlightLocalesConfigFromCode(code: string) {
export async function getStarlightLocalesConfigFromCode(code: string, readJSON: JSONReader) {
const ast = parseCode(code)
const starlightConfig = getStarlightConfig(ast)

const locales = getStarlightLocalesConfig(ast, starlightConfig)
const locales = await getStarlightLocalesConfig(ast, starlightConfig, readJSON)
const defaultLocale =
getStarlightDefaultLocaleConfig(starlightConfig) ??
Object.entries(locales).find(([name]) => name === 'root')?.[1].lang
Expand Down Expand Up @@ -115,7 +119,7 @@ function getStarlightConfig(ast: File) {
return starlightConfig
}

function getStarlightLocalesConfig(ast: File, starlightConfig: ObjectExpression) {
async function getStarlightLocalesConfig(ast: File, starlightConfig: ObjectExpression, readJSON: JSONReader) {
const localesProperty = starlightConfig.properties.find(
(property) =>
isObjectProperty(property) &&
Expand All @@ -132,7 +136,7 @@ function getStarlightLocalesConfig(ast: File, starlightConfig: ObjectExpression)
}

const localesObjectExpression = isIdentifier(localesProperty.value)
? getObjectExpressionFromIdentifier(ast, localesProperty.value)
? await getObjectExpressionFromIdentifier(ast, localesProperty.value, readJSON)
: localesProperty.value

if (!localesObjectExpression) {
Expand Down Expand Up @@ -189,7 +193,7 @@ function getStarlightDefaultLocaleConfig(starlightConfig: ObjectExpression) {
: undefined
}

function getObjectExpressionFromIdentifier(ast: File, identifier: Identifier) {
async function getObjectExpressionFromIdentifier(ast: File, identifier: Identifier, readJSON: JSONReader) {
let objectExpression: ObjectExpression | undefined

for (const bodyNode of ast.program.body) {
Expand All @@ -199,6 +203,14 @@ function getObjectExpressionFromIdentifier(ast: File, identifier: Identifier) {
? bodyNode.declaration
: undefined

if (isImportDeclaration(bodyNode)) {
const jsonObjectExpression = await tryGetObjectExpressionFromJSONImport(readJSON, identifier, bodyNode)

if (jsonObjectExpression) {
return jsonObjectExpression
}
}

if (!variableDeclaration) {
continue
}
Expand All @@ -224,3 +236,51 @@ function isLocaleObject(obj: unknown): obj is Locale {
function getObjectPropertyName(property: ObjectProperty) {
return isIdentifier(property.key) ? property.key.name : isStringLiteral(property.key) ? property.key.value : undefined
}

async function tryGetObjectExpressionFromJSONImport(
readJSON: JSONReader,
identifier: Identifier,
importDeclaration: ImportDeclaration,
) {
const identifierDefaultSPecifier = importDeclaration.specifiers.find(
(specifier) => isImportDefaultSpecifier(specifier) && specifier.local.name === identifier.name,
)

if (!identifierDefaultSPecifier) {
return
}

const source = importDeclaration.source.value

if (!source.endsWith('.json') || !source.startsWith('.')) {
return
}

const jsonStr = await readJSON(source)

if (jsonStr.trim().length === 0) {
throw new Error('The imported JSON locales configuration is empty.')
}

try {
const jsonAST = parse(`export default ${jsonStr}`, { sourceType: 'unambiguous', plugins: ['typescript'] })

if (jsonAST.errors.length > 0) {
throw new Error(`The imported JSON locales configuration contains errors.`)
}

if (
jsonAST.program.body.length !== 1 ||
!isExportDefaultDeclaration(jsonAST.program.body[0]) ||
!isObjectExpression(jsonAST.program.body[0].declaration)
) {
return
}

return jsonAST.program.body[0].declaration
} catch (error) {
throw new Error('Failed to parse imported JSON locales configuration.', { cause: error })
}
}

type JSONReader = (relativePath: string) => Promise<string>
10 changes: 9 additions & 1 deletion src/libs/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,15 @@ export async function getStarlightLocalesConfig(configUri: Uri): Promise<Locales
const configData = await workspace.fs.readFile(configUri)
const configStr = Buffer.from(configData).toString('utf8')

return getStarlightLocalesConfigFromCode(configStr)
return getStarlightLocalesConfigFromCode(configStr, async (relativePath) => {
try {
const jsonData = await workspace.fs.readFile(Uri.joinPath(Uri.joinPath(configUri, '..'), relativePath))

return Buffer.from(jsonData).toString('utf8')
} catch {
throw new Error('Failed to read imported JSON locales configuration.')
}
})
}

export interface Locale {
Expand Down
185 changes: 158 additions & 27 deletions tests/ast.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { expect, test } from 'vitest'
import { expect, test, vi } from 'vitest'

import { getStarlightLocalesConfigFromCode } from '../src/libs/ast'

Expand All @@ -24,8 +24,8 @@ const expectedCommonLocales = {
},
}

test('finds locales inlined in the configuration', () => {
const config = getStarlightLocalesConfigFromCode(`import { defineConfig } from 'astro/config';
test('finds locales inlined in the configuration', async () => {
const config = await getStarlightLocalesTestConfigFromCode(`import { defineConfig } from 'astro/config';
import starlight from '@astrojs/starlight';
export default defineConfig({
Expand All @@ -41,8 +41,8 @@ export default defineConfig({
expect(config.locales).toEqual(expectedCommonLocales)
})

test('finds locales referenced in the configuration', () => {
const config = getStarlightLocalesConfigFromCode(`import { defineConfig } from 'astro/config';
test('finds locales referenced in the configuration', async () => {
const config = await getStarlightLocalesTestConfigFromCode(`import { defineConfig } from 'astro/config';
import starlight from '@astrojs/starlight';
const locales = ${commonLocales}
Expand All @@ -60,8 +60,8 @@ export default defineConfig({
expect(config.locales).toEqual(expectedCommonLocales)
})

test('finds locales referenced and exported in the configuration', () => {
const config = getStarlightLocalesConfigFromCode(`import { defineConfig } from 'astro/config';
test('finds locales referenced and exported in the configuration', async () => {
const config = await getStarlightLocalesTestConfigFromCode(`import { defineConfig } from 'astro/config';
import starlight from '@astrojs/starlight';
export const locales = ${commonLocales}
Expand All @@ -79,9 +79,10 @@ export default defineConfig({
expect(config.locales).toEqual(expectedCommonLocales)
})

test('throws with no locales configuration', () => {
expect(() =>
getStarlightLocalesConfigFromCode(`import { defineConfig } from 'astro/config';
test('throws with no locales configuration', async () => {
await expect(
async () =>
await getStarlightLocalesTestConfigFromCode(`import { defineConfig } from 'astro/config';
import starlight from '@astrojs/starlight';
export default defineConfig({
Expand All @@ -91,12 +92,13 @@ export default defineConfig({
}),
],
});`),
).toThrowError('Failed to find locales in Starlight configuration.')
).rejects.toThrowError('Failed to find locales in Starlight configuration.')
})

test('throws with no locales to translate', () => {
expect(() =>
getStarlightLocalesConfigFromCode(`import { defineConfig } from 'astro/config';
test('throws with no locales to translate', async () => {
await expect(
async () =>
await getStarlightLocalesTestConfigFromCode(`import { defineConfig } from 'astro/config';
import starlight from '@astrojs/starlight';
export default defineConfig({
Expand All @@ -109,12 +111,13 @@ export default defineConfig({
}),
],
});`),
).toThrowError('Failed to find any Starlight locale to translate.')
).rejects.toThrowError('Failed to find any Starlight locale to translate.')
})

test('throws with no locales to translate when using the `defaultLocale` property', () => {
expect(() =>
getStarlightLocalesConfigFromCode(`import { defineConfig } from 'astro/config';
test('throws with no locales to translate when using the `defaultLocale` property', async () => {
await expect(
async () =>
await getStarlightLocalesTestConfigFromCode(`import { defineConfig } from 'astro/config';
import starlight from '@astrojs/starlight';
export default defineConfig({
Expand All @@ -128,12 +131,13 @@ export default defineConfig({
}),
],
});`),
).toThrowError('Failed to find any Starlight locale to translate.')
).rejects.toThrowError('Failed to find any Starlight locale to translate.')
})

test('throws with no default locale', () => {
expect(() =>
getStarlightLocalesConfigFromCode(`import { defineConfig } from 'astro/config';
test('throws with no default locale', async () => {
await expect(
async () =>
await getStarlightLocalesTestConfigFromCode(`import { defineConfig } from 'astro/config';
import starlight from '@astrojs/starlight';
export default defineConfig({
Expand All @@ -147,16 +151,143 @@ export default defineConfig({
}),
],
});`),
).toThrowError('Failed to find Starlight default locale.')
).rejects.toThrowError('Failed to find Starlight default locale.')
})

test('throws with no starlight integration', () => {
expect(() =>
getStarlightLocalesConfigFromCode(`import { defineConfig } from 'astro/config';
test('throws with no starlight integration', async () => {
await expect(
async () =>
await getStarlightLocalesTestConfigFromCode(`import { defineConfig } from 'astro/config';
import starlight from '@astrojs/starlight';
export default defineConfig({
integrations: [],
});`),
).toThrowError('Failed to find the `starlight` integration in the Astro configuration.')
).rejects.toThrowError('Failed to find the `starlight` integration in the Astro configuration.')
})

test('finds locales imported from a relative JSON file', async () => {
expect.assertions(3)

const config = await getStarlightLocalesTestConfigFromCode(
`import { defineConfig } from 'astro/config';
import starlight from '@astrojs/starlight';
import locales from './locales.json';
export default defineConfig({
integrations: [
starlight({
title: 'Starlight',
locales,
}),
],
});`,
(relativePath) => {
expect(relativePath).toBe('./locales.json')

return Promise.resolve(commonLocales)
},
)

expect(config.defaultLocale).toBe('en')
expect(config.locales).toEqual(expectedCommonLocales)
})

test('throws with empty imported JSON locales configuration', async () => {
await expect(
async () =>
await getStarlightLocalesTestConfigFromCode(
`import { defineConfig } from 'astro/config';
import starlight from '@astrojs/starlight';
import locales from './locales.json';
export default defineConfig({
integrations: [
starlight({
title: 'Starlight',
locales,
}),
],
});`,
() => Promise.resolve(''),
),
).rejects.toThrowError('The imported JSON locales configuration is empty.')
})

test('throws with invalid imported JSON locales configuration', async () => {
await expect(
async () =>
await getStarlightLocalesTestConfigFromCode(
`import { defineConfig } from 'astro/config';
import starlight from '@astrojs/starlight';
import locales from './locales.json';
export default defineConfig({
integrations: [
starlight({
title: 'Starlight',
locales,
}),
],
});`,
() => Promise.resolve('-'),
),
).rejects.toThrowError('Failed to parse imported JSON locales configuration.')
})

test('does not read non JSON locales configuration', async () => {
const readJSONSpy = vi.fn()

await expect(
async () =>
await getStarlightLocalesTestConfigFromCode(
`import { defineConfig } from 'astro/config';
import starlight from '@astrojs/starlight';
import locales from './locales.js';
export default defineConfig({
integrations: [
starlight({
title: 'Starlight',
locales,
}),
],
});`,
readJSONSpy,
),
).rejects.toThrowError('Failed to find valid locales configuration in Starlight configuration.')

expect(readJSONSpy).not.toHaveBeenCalled()
})

test('does not read non relative JSON locales configuration', async () => {
const readJSONSpy = vi.fn()

await expect(
async () =>
await getStarlightLocalesTestConfigFromCode(
`import { defineConfig } from 'astro/config';
import starlight from '@astrojs/starlight';
import locales from 'pkg/locales.json';
export default defineConfig({
integrations: [
starlight({
title: 'Starlight',
locales,
}),
],
});`,
readJSONSpy,
),
).rejects.toThrowError('Failed to find valid locales configuration in Starlight configuration.')

expect(readJSONSpy).not.toHaveBeenCalled()
})

function getStarlightLocalesTestConfigFromCode(
code: string,
readJSON?: Parameters<typeof getStarlightLocalesConfigFromCode>[1],
) {
return getStarlightLocalesConfigFromCode(code, readJSON ?? (() => Promise.resolve('test')))
}

0 comments on commit 5f7fec9

Please sign in to comment.