Skip to content

Commit bf89a1e

Browse files
committed
Add first-class UI extension primitives
1 parent 001e9c4 commit bf89a1e

9 files changed

Lines changed: 369 additions & 21 deletions

File tree

packages/cli/src/index.ts

Lines changed: 94 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -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

101105
function 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+
11411223
function 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

packages/schema/schemas/tokenhost-ths.schema.json

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,32 @@
3434
"description": "Theme tokens (implementation-defined).",
3535
"additionalProperties": true
3636
},
37+
"ui": {
38+
"type": "object",
39+
"additionalProperties": false,
40+
"properties": {
41+
"homePage": {
42+
"type": "object",
43+
"additionalProperties": false,
44+
"properties": {
45+
"mode": {
46+
"type": "string",
47+
"enum": ["generated", "custom"]
48+
}
49+
}
50+
},
51+
"extensions": {
52+
"type": "object",
53+
"additionalProperties": false,
54+
"properties": {
55+
"directory": {
56+
"type": "string",
57+
"description": "Path to UI override files, resolved relative to the schema file."
58+
}
59+
}
60+
}
61+
}
62+
},
3763
"features": {
3864
"type": "object",
3965
"additionalProperties": false,
@@ -124,7 +150,20 @@
124150
},
125151
"ui": {
126152
"type": "object",
127-
"additionalProperties": true
153+
"additionalProperties": true,
154+
"properties": {
155+
"component": {
156+
"type": "string",
157+
"enum": ["default", "externalLink"]
158+
},
159+
"label": {
160+
"type": "string"
161+
},
162+
"target": {
163+
"type": "string",
164+
"enum": ["_blank", "_self"]
165+
}
166+
}
128167
}
129168
},
130169
"allOf": [

packages/schema/src/lint.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,16 @@ function isSafeAutoExpr(expr: string): boolean {
6767
export function lintThs(schema: ThsSchema): Issue[] {
6868
const issues: Issue[] = [];
6969

70+
if (schema.app.ui?.homePage?.mode === 'custom' && !String(schema.app.ui?.extensions?.directory ?? '').trim()) {
71+
issues.push(
72+
warn(
73+
'/app/ui/extensions/directory',
74+
'lint.app.ui.custom_home_without_extensions',
75+
'app.ui.homePage.mode is "custom" but no app.ui.extensions.directory is configured.'
76+
)
77+
);
78+
}
79+
7080
const collectionNames = new Set<string>();
7181
for (let i = 0; i < schema.collections.length; i++) {
7282
const c = schema.collections[i]!;
@@ -102,6 +112,16 @@ export function lintThs(schema: ThsSchema): Issue[] {
102112
issues.push(err(`${fPath}/decimals`, 'lint.field.decimal_range', '"decimals" must be in the range 0..18.'));
103113
}
104114
}
115+
116+
if (f.ui?.component === 'externalLink' && !['string', 'image', 'externalReference'].includes(f.type)) {
117+
issues.push(
118+
warn(
119+
`${fPath}/ui/component`,
120+
'lint.field.ui.external_link_type',
121+
`Field "${f.name}" uses ui.component="externalLink" but has type "${f.type}". Link rendering is usually intended for string/image/externalReference fields.`
122+
)
123+
);
124+
}
105125
}
106126

107127
// createRules.required
@@ -200,4 +220,3 @@ export function lintThs(schema: ThsSchema): Issue[] {
200220

201221
return issues;
202222
}
203-

packages/schema/src/types.ts

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,28 @@ export interface ThsAppFeatures {
77
onChainIndexing?: boolean;
88
}
99

10+
export type ThsHomePageMode = 'generated' | 'custom';
11+
12+
export interface ThsAppUiHomePage {
13+
mode?: ThsHomePageMode;
14+
}
15+
16+
export interface ThsAppUiExtensions {
17+
directory?: string;
18+
}
19+
20+
export interface ThsAppUi {
21+
homePage?: ThsAppUiHomePage;
22+
extensions?: ThsAppUiExtensions;
23+
}
24+
1025
export interface ThsApp {
1126
name: string;
1227
slug: string;
1328
description?: string;
1429
theme?: Record<string, unknown>;
1530
features?: ThsAppFeatures;
31+
ui?: ThsAppUi;
1632
}
1733

1834
export type FieldType =
@@ -34,7 +50,12 @@ export interface ThsField {
3450
decimals?: number;
3551
default?: unknown;
3652
validation?: Record<string, unknown>;
37-
ui?: Record<string, unknown>;
53+
ui?: {
54+
component?: 'default' | 'externalLink';
55+
label?: string;
56+
target?: '_blank' | '_self';
57+
[key: string]: unknown;
58+
};
3859
}
3960

4061
export type Access = 'public' | 'owner' | 'allowlist' | 'role';
@@ -113,4 +134,3 @@ export interface ThsSchema {
113134
collections: Collection[];
114135
metadata?: Record<string, unknown>;
115136
}
116-

packages/templates/next-export-ui/app/[collection]/view/ClientPage.tsx

Lines changed: 22 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import { chainFromId } from '../../../src/lib/chains';
99
import { makePublicClient } from '../../../src/lib/clients';
1010
import { formatNumeric, shortAddress } from '../../../src/lib/format';
1111
import { fetchManifest, getPrimaryDeployment } from '../../../src/lib/manifest';
12-
import { getCollection, transferEnabled, type ThsCollection, type ThsField } from '../../../src/lib/ths';
12+
import { fieldLinkUi, getCollection, transferEnabled, type ThsCollection, type ThsField } from '../../../src/lib/ths';
1313
import { submitWriteTx } from '../../../src/lib/tx';
1414
import TxStatus, { type TxPhase } from '../../../src/components/TxStatus';
1515

@@ -28,6 +28,26 @@ function fieldIndex(collection: ThsCollection, field: ThsField): number {
2828
return 9 + Math.max(0, idx);
2929
}
3030

31+
function renderFieldValue(field: ThsField, rendered: string) {
32+
if (!rendered) return <span className="badge"></span>;
33+
34+
const linkUi = fieldLinkUi(field);
35+
if (field.type === 'image') {
36+
// eslint-disable-next-line @next/next/no-img-element
37+
return <img src={String(rendered)} alt={field.name} style={{ maxWidth: 360, borderRadius: 12, border: '1px solid var(--border)' }} />;
38+
}
39+
40+
if (linkUi) {
41+
return (
42+
<a className="btn" href={String(rendered)} target={linkUi.target} rel={linkUi.target === '_blank' ? 'noreferrer' : undefined}>
43+
{linkUi.label || rendered}
44+
</a>
45+
);
46+
}
47+
48+
return <span className="badge">{rendered}</span>;
49+
}
50+
3151
export default function ViewRecordPage(props: { params: { collection: string } }) {
3252
const collectionName = props.params.collection;
3353
const collection = useMemo(() => getCollection(collectionName), [collectionName]);
@@ -299,14 +319,7 @@ export default function ViewRecordPage(props: { params: { collection: string } }
299319
return (
300320
<React.Fragment key={f.name}>
301321
<div>{f.name}</div>
302-
<div>
303-
{f.type === 'image' && rendered ? (
304-
// eslint-disable-next-line @next/next/no-img-element
305-
<img src={String(rendered)} alt={f.name} style={{ maxWidth: 360, borderRadius: 12, border: '1px solid var(--border)' }} />
306-
) : (
307-
<span className="badge">{rendered || '—'}</span>
308-
)}
309-
</div>
322+
<div>{renderFieldValue(f, rendered)}</div>
310323
</React.Fragment>
311324
);
312325
})}

0 commit comments

Comments
 (0)