@@ -78,7 +78,11 @@ function defaultStudioFormState(): ThsSchema {
7878 name : 'My App' ,
7979 slug : 'my-app' ,
8080 description : '' ,
81- features : { uploads : false , onChainIndexing : true , indexer : false , delegation : false }
81+ features : { uploads : false , onChainIndexing : true , indexer : false , delegation : false } ,
82+ ui : {
83+ homePage : { mode : 'generated' } ,
84+ extensions : { }
85+ }
8286 } ,
8387 collections : [
8488 {
@@ -100,6 +104,7 @@ function defaultStudioFormState(): ThsSchema {
100104
101105function buildStudioPreview ( schema : ThsSchema ) : {
102106 app : { name : string ; slug : string } ;
107+ ui : { homePageMode : string ; extensionDirectory : string | null } ;
103108 collections : Array < {
104109 name : string ;
105110 routes : string [ ] ;
@@ -135,6 +140,10 @@ function buildStudioPreview(schema: ThsSchema): {
135140 name : String ( schema ?. app ?. name ?? '' ) ,
136141 slug : String ( schema ?. app ?. slug ?? '' )
137142 } ,
143+ ui : {
144+ homePageMode : String ( schema ?. app ?. ui ?. homePage ?. mode ?? 'generated' ) ,
145+ extensionDirectory : schema ?. app ?. ui ?. extensions ?. directory ? String ( schema . app . ui . extensions . directory ) : null
146+ } ,
138147 collections
139148 } ;
140149}
@@ -158,6 +167,18 @@ function normalizeStudioFormState(input: any): ThsSchema {
158167 onChainIndexing : Boolean ( appIn . features ?. onChainIndexing ) ,
159168 indexer : Boolean ( appIn . features ?. indexer ) ,
160169 delegation : Boolean ( appIn . features ?. delegation )
170+ } ,
171+ ui : {
172+ homePage : {
173+ mode : appIn . ui ?. homePage ?. mode === 'custom' ? 'custom' : 'generated'
174+ } ,
175+ extensions :
176+ appIn . ui ?. extensions && typeof appIn . ui . extensions === 'object'
177+ ? {
178+ directory :
179+ appIn . ui . extensions . directory == null ? undefined : String ( appIn . ui . extensions . directory )
180+ }
181+ : undefined
161182 }
162183 } ,
163184 collections : collectionsIn . map ( ( c : any ) => {
@@ -180,7 +201,20 @@ function normalizeStudioFormState(input: any): ThsSchema {
180201 decimals : f ?. decimals == null || f ?. decimals === '' ? undefined : Number ( f . decimals ) ,
181202 default : f ?. default ,
182203 validation : f ?. validation && typeof f . validation === 'object' ? f . validation : undefined ,
183- ui : f ?. ui && typeof f . ui === 'object' ? f . ui : undefined
204+ ui :
205+ f ?. ui && typeof f . ui === 'object'
206+ ? {
207+ ...( f . ui || { } ) ,
208+ component :
209+ f . ui . component === 'externalLink'
210+ ? 'externalLink'
211+ : f . ui . component === 'default'
212+ ? 'default'
213+ : undefined ,
214+ label : f . ui . label == null ? undefined : String ( f . ui . label ) ,
215+ target : f . ui . target === '_self' ? '_self' : f . ui . target === '_blank' ? '_blank' : undefined
216+ }
217+ : undefined
184218 } ) ) ,
185219 createRules : {
186220 required : Array . isArray ( createRules . required ) ? createRules . required . map ( ( x : any ) => String ( x ) ) : [ ] ,
@@ -631,7 +665,13 @@ function renderStudioHtml(): string {
631665 state = {
632666 thsVersion: '2025-12',
633667 schemaVersion: '0.0.1',
634- app: { name: 'My App', slug: 'my-app', description: '', features: { uploads: false, onChainIndexing: true, indexer: false, delegation: false } },
668+ app: {
669+ name: 'My App',
670+ slug: 'my-app',
671+ description: '',
672+ features: { uploads: false, onChainIndexing: true, indexer: false, delegation: false },
673+ ui: { homePage: { mode: 'generated' }, extensions: {} }
674+ },
635675 collections: [],
636676 metadata: {}
637677 };
@@ -653,7 +693,7 @@ function renderStudioHtml(): string {
653693 }
654694
655695 function makeField() {
656- return { name: 'field', type: 'string', required: false, decimals: null };
696+ return { name: 'field', type: 'string', required: false, decimals: null, ui: { component: 'default', label: '', target: '_blank' } };
657697 }
658698
659699 function setPath(path, value) {
@@ -714,6 +754,11 @@ function renderStudioHtml(): string {
714754 '<div><label>Type</label><select data-bind=\"collections.' + ci + '.fields.' + fi + '.type\">' + fieldTypes.map((t) => opt(t, f.type)).join('') + '</select></div>' +
715755 '<div><label>Decimals</label><input type=\"number\" data-bind=\"collections.' + ci + '.fields.' + fi + '.decimals\" value=\"' + esc(f.decimals == null ? '' : f.decimals) + '\"></div>' +
716756 '</div>' +
757+ '<div class=\"grid3\">' +
758+ '<div><label>UI component</label><select data-bind=\"collections.' + ci + '.fields.' + fi + '.ui.component\">' + opt('default', f.ui?.component || 'default') + opt('externalLink', f.ui?.component || 'default') + '</select></div>' +
759+ '<div><label>UI label</label><input type=\"text\" data-bind=\"collections.' + ci + '.fields.' + fi + '.ui.label\" value=\"' + esc(f.ui?.label || '') + '\"></div>' +
760+ '<div><label>Link target</label><select data-bind=\"collections.' + ci + '.fields.' + fi + '.ui.target\">' + opt('_blank', f.ui?.target || '_blank') + opt('_self', f.ui?.target || '_blank') + '</select></div>' +
761+ '</div>' +
717762 '<label><input type=\"checkbox\" data-bind-check=\"collections.' + ci + '.fields.' + fi + '.required\" ' + (f.required ? 'checked' : '') + '> required</label> ' +
718763 '<button data-action=\"del-field\" data-ci=\"' + ci + '\" data-fi=\"' + fi + '\">Remove</button>' +
719764 '</div>'
@@ -776,6 +821,10 @@ function renderStudioHtml(): string {
776821 '<label><input type=\"checkbox\" data-bind-check=\"app.features.onChainIndexing\" ' + (state.app?.features?.onChainIndexing ? 'checked' : '') + '> onChainIndexing</label>' +
777822 '<label><input type=\"checkbox\" data-bind-check=\"app.features.indexer\" ' + (state.app?.features?.indexer ? 'checked' : '') + '> indexer</label>' +
778823 '<label><input type=\"checkbox\" data-bind-check=\"app.features.delegation\" ' + (state.app?.features?.delegation ? 'checked' : '') + '> delegation</label></div>' +
824+ '<div class=\"sectionTitle\">UI</div><div class=\"grid2\">' +
825+ '<div><label>home page mode</label><select data-bind=\"app.ui.homePage.mode\">' + opt('generated', state.app?.ui?.homePage?.mode || 'generated') + opt('custom', state.app?.ui?.homePage?.mode || 'generated') + '</select></div>' +
826+ '<div><label>extensions directory</label><input type=\"text\" data-bind=\"app.ui.extensions.directory\" value=\"' + esc(state.app?.ui?.extensions?.directory || '') + '\" placeholder=\"ui-overrides\"></div>' +
827+ '</div>' +
779828 '</div>' +
780829 '<div class=\"card\"><div class=\"sectionTitle\">Collections</div><div>' + collectionsNav + '</div>' + (state.collections.length > 0 ? renderCollectionEditor(c, selectedCollectionIndex) : '<div class=\"muted\">No collections yet.</div>') + '</div>';
781830
@@ -1138,6 +1187,39 @@ function materializeCollectionRoutes(uiDir: string, schema: ThsSchema) {
11381187 }
11391188}
11401189
1190+ function resolveUiExtensionsDir ( schema : ThsSchema , schemaPathForHints ?: string ) : string | null {
1191+ const declared = String ( schema . app ?. ui ?. extensions ?. directory ?? '' ) . trim ( ) ;
1192+ if ( ! declared ) return null ;
1193+ const baseDir = schemaPathForHints ? path . dirname ( path . resolve ( schemaPathForHints ) ) : process . cwd ( ) ;
1194+ return path . resolve ( baseDir , declared ) ;
1195+ }
1196+
1197+ function ensureUiCustomizationConfig ( schema : ThsSchema , schemaPathForHints ?: string ) {
1198+ const extensionsDir = resolveUiExtensionsDir ( schema , schemaPathForHints ) ;
1199+ const homePageMode = schema . app ?. ui ?. homePage ?. mode ?? 'generated' ;
1200+
1201+ if ( homePageMode === 'custom' ) {
1202+ if ( ! extensionsDir ) {
1203+ throw new Error ( 'app.ui.homePage.mode is "custom" but app.ui.extensions.directory is not configured.' ) ;
1204+ }
1205+ const homeCandidates = [ 'app/page.tsx' , 'app/page.jsx' , 'app/page.ts' , 'app/page.js' ] . map ( ( relPath ) => path . join ( extensionsDir , relPath ) ) ;
1206+ if ( ! homeCandidates . some ( ( candidate ) => fs . existsSync ( candidate ) ) ) {
1207+ throw new Error ( `app.ui.homePage.mode is "custom" but no custom home page was found in ${ extensionsDir } . Expected app/page.tsx (or js/jsx/ts).` ) ;
1208+ }
1209+ }
1210+
1211+ if ( extensionsDir && ! fs . existsSync ( extensionsDir ) ) {
1212+ throw new Error ( `Configured app.ui.extensions.directory does not exist: ${ extensionsDir } ` ) ;
1213+ }
1214+ }
1215+
1216+ function applyUiExtensions ( uiDir : string , schema : ThsSchema , schemaPathForHints ?: string ) {
1217+ ensureUiCustomizationConfig ( schema , schemaPathForHints ) ;
1218+ const extensionsDir = resolveUiExtensionsDir ( schema , schemaPathForHints ) ;
1219+ if ( ! extensionsDir ) return ;
1220+ copyDir ( extensionsDir , uiDir ) ;
1221+ }
1222+
11411223function ensureEd25519PrivateKey ( key : crypto . KeyObject ) : crypto . KeyObject {
11421224 const type = ( key as any ) . asymmetricKeyType as string | undefined ;
11431225 if ( type && type !== 'ed25519' ) {
@@ -2107,6 +2189,8 @@ function buildFromSchema(
21072189 const bakedManifestPath = path . join ( uiWorkDir , 'public' , '.well-known' , 'tokenhost' , 'manifest.json' ) ;
21082190 if ( fs . existsSync ( bakedManifestPath ) ) fs . rmSync ( bakedManifestPath , { force : true } ) ;
21092191
2192+ applyUiExtensions ( uiWorkDir , schema , opts . schemaPathForHints ) ;
2193+
21102194 runPnpmCommand ( [ 'install' ] , { cwd : uiWorkDir } ) ;
21112195 runPnpmCommand ( [ 'build' ] , { cwd : uiWorkDir } ) ;
21122196
@@ -2458,6 +2542,9 @@ program
24582542 features : {
24592543 uploads : false ,
24602544 onChainIndexing : true
2545+ } ,
2546+ ui : {
2547+ homePage : { mode : 'generated' }
24612548 }
24622549 } ,
24632550 collections : [
@@ -2923,6 +3010,7 @@ program
29233010 if ( opts . ui ) {
29243011 const templateDir = resolveNextExportUiTemplateDir ( ) ;
29253012 const uiDir = path . join ( outDir , 'ui' ) ;
3013+ fs . rmSync ( uiDir , { recursive : true , force : true } ) ;
29263014 copyDir ( templateDir , uiDir ) ;
29273015
29283016 const thsTsPath = path . join ( uiDir , 'src' , 'generated' , 'ths.ts' ) ;
@@ -2939,6 +3027,8 @@ program
29393027 console . log ( `Wrote ui/tests/ (generated app test scaffold)` ) ;
29403028 }
29413029
3030+ applyUiExtensions ( uiDir , schema , schemaPath ) ;
3031+
29423032 console . log ( `Wrote ui/ (Next.js static export template)` ) ;
29433033 }
29443034
0 commit comments