Skip to content

Commit

Permalink
feat: theme plugin
Browse files Browse the repository at this point in the history
  • Loading branch information
jakobrosenberg committed Dec 19, 2024
1 parent cf099f0 commit 8b8c847
Show file tree
Hide file tree
Showing 16 changed files with 433 additions and 1 deletion.
3 changes: 3 additions & 0 deletions lib/buildtime/RoutifyBuildtime.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { devHelperPlugin } from './plugins/devHelper/helper.js'
import { metaCapturePlugin } from './plugins/metaCapture/index.js'
import { log, logs } from './logMsgs.js'
import { omitDirFromPathPlugin } from './plugins/omitFromPath/index.js'
import { themesPlugin } from './plugins/themes/index.js'

/** @returns {Partial<RoutifyBuildtimeOptions>} */
const getDefaults = () => ({
Expand All @@ -30,6 +31,7 @@ const getDefaults = () => ({
resetFiles: ['_reset'],
fallbackFiles: ['_fallback'],
},
themes: { presets: {} },
logLevel: 3,
routesDir: {
default: 'src/routes',
Expand All @@ -48,6 +50,7 @@ const getDefaults = () => ({
devHelperPlugin,
metaCapturePlugin,
omitDirFromPathPlugin,
themesPlugin,
],
watch: false,
})
Expand Down
3 changes: 2 additions & 1 deletion lib/buildtime/plugins/omitFromPath/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ export const omitDirFromPathPlugin = {
before: 'bundler',
build: ({ instance }) => {
// find file names that are enclosed in a parenthesis
const omittedDir = node => node.file.name.match(/\(([^)]+)\)/)
const omittedDir = node =>
node.file.name.startsWith('(') && node.file.name.endsWith(')')

// remove the node and move its children to the parent
instance.nodeIndex.filter(omittedDir).forEach(node => {
Expand Down
27 changes: 27 additions & 0 deletions lib/buildtime/plugins/omitNode/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
/*
Always omit
<!-- routify:meta omit -->
Omit in production
<!-- routify:meta omit="production" -->
Omit in multiple environments
<!-- routify:meta omit=["production", "staging"]
*/

const coerceArray = val => (Array.isArray(val) ? val : [val])
const crossMatch = (a, b) => a.some(x => b.includes(x))

/** @type {RoutifyBuildtimePlugin} */
export const omitNodePlugin = {
name: 'omitNode',
after: 'filemapper',
before: 'exporter',
build: ({ instance }) => {
instance.nodeIndex.forEach(node => {
const omits = coerceArray(node.meta.omit)
const conditions = [process.env.NODE_ENV, true]
if (crossMatch(omits, conditions)) node.remove()
})
},
}
13 changes: 13 additions & 0 deletions lib/buildtime/plugins/themes/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { themes } from './themes.js'

/**
* @type {RoutifyBuildtimePlugin}
**/
export const themesPlugin = {
name: 'themesPlugin',
after: 'metaFromFile',
before: 'exporter',
build: async ctx => {
themes(ctx)
},
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
@at/blog/index
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
austrian index
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
german xmas only index
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
german xmas index
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
german index
1 change: 1 addition & 0 deletions lib/buildtime/plugins/themes/spec/example/about.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
english about
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
blog/@de/index
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
blog index
1 change: 1 addition & 0 deletions lib/buildtime/plugins/themes/spec/example/index.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
english index
187 changes: 187 additions & 0 deletions lib/buildtime/plugins/themes/spec/themes.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
import { dirname } from 'path'
import { fileURLToPath } from 'url'
import fse from 'fs-extra'
import { filemapper } from '../../../../buildtime/plugins/filemapper/lib/index.js'
import { RoutifyBuildtime } from '../../../../buildtime/RoutifyBuildtime.js'
import { themes } from '../themes.js'
import { normalizeConfig, normalizePreset } from '../utils.js'

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

const options = {
routifyDir: `${__dirname}/temp`,
routesDir: { default: `${__dirname}/example` },
}

beforeEach(() => {
fse.emptyDirSync(options.routifyDir)
})

describe('theme config', () => {
test('presets', () => {
const defaultPreset = {
preferences: [],
namespaces: [/^@_/],
rootNodes: [],
}
test('can handle shorthand ', () => {
const input = [['de', 'xmas'], 'de']
const expected = {
rootNodes: [],
namespaces: [/^@_/],
preferences: [['de', 'xmas'], ['de']],
}

const normalizedPreset = normalizePreset(input, defaultPreset)
expect(normalizedPreset).toEqual(expected)
})

test('can handle full preset', () => {
const input = {
preferences: [['de', 'xmas'], ['de']],
namespaces: ['foo'],
rootNodes: ['blog'],
}

const normalizedPreset = normalizePreset(input, defaultPreset)
expect(normalizedPreset).toEqual(input)
})
})
test('config', () => {
test('no defaults', () => {
const input = { presets: { at: [['at', 'xmas'], 'at'] } }

const normalizedConfig = normalizeConfig(input, [])
expect(normalizedConfig).toEqual({
presets: {
at: {
rootNodes: [],
namespaces: [/^@_/],
preferences: [['at', 'xmas'], ['at']],
},
},
defaults: {
file: 'en',
app: 'en',
namespaces: [/^@_/],
rootNodes: [],
},
})
})
test('with defaults', () => {
const input = {
presets: { at: [['at', 'xmas'], 'at'] },
defaults: { file: 'de', namespaces: ['foo'] },
}

const normalizedConfig = normalizeConfig(input, [])
expect(normalizedConfig).toEqual({
presets: {
at: {
rootNodes: [],
namespaces: ['foo'],
preferences: [['at', 'xmas'], ['at']],
},
},
defaults: {
file: 'de',
app: 'en',
namespaces: ['foo'],
rootNodes: [],
},
})
})
})
})

describe('themes', async () => {
const themeConfig = {
presets: {
'at-xmas': [['at', 'xmas'], ['de', 'xmas'], 'at', 'de', ['en', 'xmas'], 'en'],
at: ['at', 'de', 'en'],
'de-xmas': [['de', 'xmas'], 'de', ['en', 'xmas'], 'en'],
de: ['de', 'en'],
'en-xmas': [['en', 'xmas'], 'en'],
en: ['en'],
},
}
const instance = new RoutifyBuildtime({ ...options, themes: themeConfig })
await filemapper({ instance })
await themes({ instance })

test('creates themed root nodes', () => {
// Object.entries(instance.rootNodes).forEach(([name, rootNode]) => {
// console.log('THEME: ' + name)
// // console.log(rootNode)
// visualNodeTree(rootNode)
// })
expect(instance.rootNodes['default-theme-at-xmas']).toBeTruthy()
expect(instance.rootNodes['default-theme-de-xmas']).toBeTruthy()
expect(instance.rootNodes['default-theme-en-xmas']).toBeTruthy()
expect(instance.rootNodes['default-theme-at']).toBeTruthy()
expect(instance.rootNodes['default-theme-de']).toBeTruthy()
expect(instance.rootNodes['default-theme-en']).toBeTruthy()
expect(instance.rootNodes['default']).toBeTruthy()
})

test('can have nested folders in a theme', () => {
const node = instance.rootNodes['default-theme-at-xmas'].traverse('/blog/index')
expect(node).toBeTruthy()
expect(node.id).toBe('_default___lang__at_blog_index_svelte')
expect(node.file.dir).toEqual(
'lib/buildtime/plugins/themes/spec/example/@_lang/@at/blog',
)
})

test('themes can be nested in folders', () => {
const node = instance.rootNodes['default-theme-de-xmas'].traverse('/blog/index')
expect(node).toBeTruthy()
expect(node.id).toBe('_default_blog__de_index_svelte')
expect(node.file.dir).toEqual(
'lib/buildtime/plugins/themes/spec/example/blog/@de',
)
})

test('themes can have precedence', () => {
const indexNode = instance.rootNodes['default-theme-at-xmas'].traverse('/index')
expect(indexNode.file.dir).toEqual(
'lib/buildtime/plugins/themes/spec/example/@_lang/@de/@xmas',
)

const blogNode =
instance.rootNodes['default-theme-at-xmas'].traverse('/blog/index')
expect(blogNode.file.dir).toEqual(
'lib/buildtime/plugins/themes/spec/example/@_lang/@at/blog',
)
})

test('unwanted themes are excluded', () => {
const yes = instance.rootNodes['default-theme-de-xmas'].traverse('/dexmasonly')
const no = instance.rootNodes['default-theme-de'].traverse('/dexmasonly', {
silent: true,
})
expect(yes).toBeTruthy()
expect(no).toBeFalsy()
})
})

describe('themes can be built', async () => {
const themeConfig = {
presets: {
'at-xmas': [['at', 'xmas'], ['de', 'xmas'], 'at', 'de', ['en', 'xmas'], 'en'],
at: ['at', 'de', 'en'],
'de-xmas': [['de', 'xmas'], 'de', ['en', 'xmas'], 'en'],
de: ['de', 'en'],
'en-xmas': [['en', 'xmas'], 'en'],
en: ['en'],
},
}
const instance = new RoutifyBuildtime({ ...options, themes: themeConfig })
await instance.build()
})

const visualNodeTree = (node, depth = 0) => {
const indent = ' '.repeat(depth)
console.log(`${indent}${node.name} - ${node.meta.__themesPlugin_themes?.join(', ')}`)
node.children.forEach(child => visualNodeTree(child, depth + 1))
}
92 changes: 92 additions & 0 deletions lib/buildtime/plugins/themes/themes.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { coerceArray, arraysAreEqual, normalizeConfig } from './utils.js'

Check failure on line 1 in lib/buildtime/plugins/themes/themes.js

View workflow job for this annotation

GitHub Actions / Test on NodeJS v14.x

Argument of type '{ presets: { [x: string]: ThemeUserPreset; }; defaults: Partial<ThemeConfigDefaults> | { file: string; app: string; namespaces: RegExp[]; rootNodes: any[]; }; }' is not assignable to parameter of type 'ThemeConfig'.

Check failure on line 1 in lib/buildtime/plugins/themes/themes.js

View workflow job for this annotation

GitHub Actions / Test on NodeJS v14.x

Property 'rootName' does not exist on type 'RNodeBuildtime'.

export const themes = async ({ instance }) => {
const config = normalizeConfig(
instance.options.themes,
Object.keys(instance.rootNodes),
)
createThemedRootNodes(instance, config)
}

/**
* @param {RoutifyBuildtime} instance
* @param {import('./utils.js').ThemeConfig} config
*/
export const createThemedRootNodes = (instance, config) => {
// remove theme folders
instance.nodeIndex = instance.nodeIndex.filter(node => !node.name.startsWith('@'))
// tag nodes
instance.nodeIndex.forEach(tagNodeThemes)

Object.entries(config.presets).forEach(([name, themePreset]) => {
themePreset.rootNodes.forEach(rootNodeName => {
createThemedRootNode(instance, name, themePreset.preferences, rootNodeName)
})
})

// clean up meta props
instance.nodeIndex.forEach(node => {
delete node.meta.__themesPlugin_themes
delete node.meta.__themesPlugin_parentPath
})
}

/**
*
* @param {RoutifyBuildtime} instance
* @param {(string[]|string)[]} themePreferenceGroups
*/
export const createThemedRootNode = (
instance,
name,
themePreferenceGroups,
rootNodeName,
) => {
const rootNode = instance.rootNodes[rootNodeName].deepClone()
rootNode.name = name
rootNode.id = '_' + name
rootNode.rootName = rootNodeName + '-theme-' + name
instance.rootNodes[rootNodeName + '-theme-' + name] = rootNode
;[...themePreferenceGroups].reverse().forEach(themePreferenceGroup => {
themePreferenceGroup = coerceArray(themePreferenceGroup)
instance.nodeIndex
.filter(node => nodeMatchesThemes(node, themePreferenceGroup))
.forEach(copyNodeToTheme(rootNode))
})
// return rootNodes
}

export const nodeMatchesThemes = (node, themes) =>
node.meta.__themesPlugin_themes &&
arraysAreEqual(node.meta.__themesPlugin_themes, themes)

export const copyNodeToTheme = rootNode => node =>
copyNode(node, rootNode, node.meta.__themesPlugin_parentPath || '/')

/**
* @param {RNode} node
* @param {string} location
*/
export const copyNode = (node, rootNode, location = '/') => {
const newNode = node.clone()
const parent = rootNode.traverse(location)
// remove existing node
parent.children.find(child => child.name === newNode.name)?.remove()

parent.appendChild(newNode)
}

/**
* @param {RNode} node
*/
export const tagNodeThemes = node => {
const allTags = [...node.ancestors].reverse().map(a => a.name)

const themes = allTags
.filter(tag => tag.startsWith('@') && !tag.startsWith('@_'))
.map(tag => tag.slice(1))
const path = allTags.filter(tag => !tag.startsWith('@')).join('/')
node.meta.__themesPlugin_themes = themes
node.meta.__themesPlugin_parentPath = path
// node.meta.__themesPlugin_selfPath = (path + '/' + node.name).replace(/\/+/, '/')
}
Loading

0 comments on commit 8b8c847

Please sign in to comment.