Skip to content

Commit f27403a

Browse files
fehnomenaltoddeTV
andauthored
feat: generate model declaration & runtime code using handlebars (#16)
Co-authored-by: Thorsten Seyschab <business@todde.tv>
1 parent 3125a6a commit f27403a

File tree

18 files changed

+441
-0
lines changed

18 files changed

+441
-0
lines changed

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,3 +26,7 @@ node_modules/
2626

2727
# generated build
2828
dist
29+
30+
# generated files
31+
*.hbs.ts
32+
register__generated.ts

README.dev.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,37 @@ the GitHub workflow (CI action) that:
7676

7777
You can use `npx changelogen --dry` to generate the new changelog in a dry run to preview in development.
7878

79+
## Template Compilation and Usage
80+
81+
It uses Handlebars to generate the code.
82+
83+
Here's a more detailed explanation of how everything works:
84+
85+
1. During local installation, all Handlebars templates are precompiled into JavaScript code so they can be bundled
86+
without the need to manually specify which `*.hbs` files need to be included or reference them at runtime.
87+
88+
1. The process works by scanning all files in the `src` directory and generating a TypeScript file for each
89+
found template.
90+
2. These files contain the precompiled Handlebars code and export the instantiated template (which is a regular
91+
function) as the default export. This allows the templates to be used simply by importing them.
92+
3. The script treats partials (identified by being in `_partials` directories) differently: They are also
93+
precompiled as described above, but for each `_partials` directory, an index file is generated that registers
94+
all the contained partials on import.
95+
4. To generate these two types of files, the script itself uses Handlebars templates. These do not need to be
96+
precompiled or bundled as they are only used during the plugin's development.
97+
98+
2. The templates themselves are fairly simple but split across multiple files and helpers to reduce duplication.
99+
100+
1. The `fn_prefix` partial generates code for the node getter function and is adaptable for both `.d.ts`
101+
and `.js` code. For example, in declaration files, the use of `async function` is not allowed.
102+
2. The `children` partial is recursive and constructs the tree of child indices, using a helper function to
103+
build the index array.
104+
3. The `imports` partial builds a list of imports and takes into account type-only imports. Currently, this is
105+
not required and is a remnant of a previous iteration. I decided to keep it in, as it might be useful in the future.
106+
4. The main templates, `declaration` and `runtime`, are now simply wrappers around these partials.
107+
108+
See [PR #16](https://github.com/toddeTV/gltf-type-toolkit/pull/16), which introduces this logic for the first time.
109+
79110
## Docs and helper websites
80111

81112
\[currently none\]

package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,8 @@
108108
"pnpm": "9"
109109
},
110110
"scripts": {
111+
"prepare": "run-s \"precompile-templates\"",
112+
"precompile-templates": "esno scripts/precompile-templates.ts",
111113
"build": "tsup",
112114
"dev": "tsup --watch src",
113115
"build:post": "esno scripts/postbuild.ts",
@@ -158,6 +160,7 @@
158160
}
159161
},
160162
"dependencies": {
163+
"handlebars": "~4.7.8"
161164
},
162165
"devDependencies": {
163166
"@antfu/eslint-config": "~3.16.0",

pnpm-lock.yaml

Lines changed: 35 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

scripts/precompile-templates.ts

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import { readdir, readFile, writeFile } from 'node:fs/promises'
2+
import { dirname, join, resolve } from 'node:path'
3+
import { fileURLToPath } from 'node:url'
4+
import Handlebars from 'handlebars'
5+
6+
const __dirname = dirname(fileURLToPath(import.meta.url))
7+
8+
// This template is used to generate the files.
9+
const template = Handlebars.compile(
10+
await readFile(resolve(__dirname, './templates/template.hbs'), { encoding: 'utf8' }),
11+
{ strict: true },
12+
)
13+
14+
// Begin traversal at this directory.
15+
const dirsToScan = [resolve(__dirname, '../src/')]
16+
17+
// Marker for partial templates.
18+
const PARTIALS_DIR = '_partials'
19+
20+
// Memory to build index files for partials.
21+
const partialsIndices: Record<string, string[]> = {}
22+
23+
// This template is used to generate the partial index files.
24+
const partialIndex = Handlebars.compile(
25+
await readFile(resolve(__dirname, './templates/register.hbs'), { encoding: 'utf8' }),
26+
{ strict: true },
27+
)
28+
29+
while (dirsToScan.length > 0) {
30+
const dir = dirsToScan.shift()
31+
if (dir) {
32+
// Get directory contents.
33+
const entries = await readdir(dir, { withFileTypes: true })
34+
35+
for (const entry of entries) {
36+
const fullPath = join(entry.parentPath, entry.name)
37+
38+
if (entry.isDirectory()) {
39+
// Enqueue directories into the list to check. This does a breadth-first search.
40+
dirsToScan.push(fullPath)
41+
}
42+
else if (entry.name.endsWith('.hbs')) {
43+
// Precompile the template...
44+
const templateSpec = Handlebars.precompile(await readFile(fullPath, { encoding: 'utf8' }), {
45+
strict: true,
46+
})
47+
48+
// ... and save it into a file.
49+
await writeFile(
50+
`${fullPath}.ts`,
51+
template({
52+
templateSpec,
53+
}),
54+
{ encoding: 'utf8' },
55+
)
56+
57+
// Check if this is a partial template.
58+
const partialsIdx = fullPath.indexOf(PARTIALS_DIR)
59+
if (partialsIdx > -1) {
60+
const partialDir = fullPath.slice(0, partialsIdx + PARTIALS_DIR.length)
61+
const partialName = fullPath.slice(partialsIdx + PARTIALS_DIR.length + 1)
62+
63+
const index = partialsIndices[partialDir] ?? (partialsIndices[partialDir] = [])
64+
index.push(partialName)
65+
}
66+
}
67+
}
68+
}
69+
}
70+
71+
// Generate index files for partials that register them on import.
72+
for (const [dir, partials] of Object.entries(partialsIndices)) {
73+
await writeFile(
74+
join(dir, 'register__generated.ts'),
75+
partialIndex({
76+
partials,
77+
}),
78+
{ encoding: 'utf8' },
79+
)
80+
}

scripts/templates/register.hbs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import Handlebars from 'handlebars';
2+
{{#each partials}}
3+
import partial_{{@index}} from './{{.}}.js';
4+
{{/each}}
5+
6+
{{#each partials}}
7+
Handlebars.registerPartial('{{.}}', partial_{{@index}});
8+
{{/each}}

scripts/templates/template.hbs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import Handlebars from 'handlebars';
2+
3+
const spec = {{{templateSpec}}};
4+
5+
export default Handlebars.template(spec);

src/core/analyze.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import type { Object3D } from 'three'
2+
import type { GLTF } from 'three-stdlib'
3+
import { Imports } from './utils/imports.js'
4+
5+
export interface GltfNode {
6+
name: string
7+
type: string
8+
index: number
9+
children: GltfNode[]
10+
}
11+
12+
export type GltfAnalysis = ReturnType<typeof analyzeGltfModel>
13+
14+
export function analyzeGltfModel({ scenes }: Pick<GLTF, 'scenes'>): { imports: Imports, scenes: GltfNode[] } {
15+
const imports = new Imports()
16+
17+
const sceneNodes: GltfNode[] = []
18+
19+
for (let index = 0; index < scenes.length; index++) {
20+
const scene = scenes[index]
21+
22+
const node: GltfNode = {
23+
children: [],
24+
index,
25+
name: toValidIdentifier(`${scene.name}Scene`),
26+
type: imports.addImport('three', scene.type, true),
27+
}
28+
29+
collectNamedChildren(imports, scene, node)
30+
31+
sceneNodes.push(node)
32+
}
33+
34+
return {
35+
imports,
36+
scenes: sceneNodes,
37+
}
38+
}
39+
40+
function collectNamedChildren(imports: Imports, obj: Object3D, parentNode: GltfNode): void {
41+
for (let index = 0; index < obj.children.length; index++) {
42+
const child = obj.children[index]
43+
44+
if (child.name) {
45+
const node: GltfNode = {
46+
children: [],
47+
index,
48+
name: toValidIdentifier(child.name),
49+
type: imports.addImport('three', child.type, true),
50+
}
51+
52+
parentNode.children.push(node)
53+
54+
collectNamedChildren(imports, child, node)
55+
}
56+
}
57+
}
58+
59+
function toValidIdentifier(name: string): string {
60+
if (/^[a-z_]/i.test(name)) {
61+
return name
62+
}
63+
64+
return `_${name}`
65+
}

src/core/generate/handlebars.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import Handlebars from 'handlebars'
2+
import './templates/_partials/register__generated.js'
3+
4+
// Join strings with a separator.
5+
Handlebars.registerHelper('join', (list, options) =>
6+
list.map((item: any) => options.fn(item, { data: options.data })).join(options.hash.sep))
7+
8+
// Create an array with a single element.
9+
Handlebars.registerHelper('singleton', val => [val])
10+
11+
// Append an element to an array.
12+
Handlebars.registerHelper('append', (arr, val) => [...arr, val])
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import type { GltfAnalysis, GltfNode } from '../analyze.js'
2+
import declaration from './templates/declaration.hbs.js'
3+
import './handlebars.js'
4+
5+
export function generateModelDeclaration(
6+
{ imports, scenes }: GltfAnalysis,
7+
identifiers: {
8+
nodeGetter: string
9+
},
10+
): string {
11+
return declaration({
12+
flattenedTree: scenes.flatMap(scene => flattenNodes(scene, [])),
13+
identifiers,
14+
imports: imports.toTemplateData(true),
15+
scenes,
16+
}).trim()
17+
}
18+
19+
function flattenNodes(node: GltfNode, prefixPath: string[]): { names: string[], type: string }[] {
20+
prefixPath = [...prefixPath, node.name]
21+
22+
return [
23+
{ names: prefixPath, type: node.type },
24+
...node.children.flatMap(child => flattenNodes(child, prefixPath)),
25+
]
26+
}

0 commit comments

Comments
 (0)