@fate-app/mod-build 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,62 @@
1
+ import { type FateModCapability } from '@fate-app/mod-types';
2
+ import type * as VueModule from 'vue';
3
+ import type * as VueI18nModule from 'vue-i18n';
4
+ import type * as ThreeModule from 'three';
5
+ import type * as CannonEsModule from 'cannon-es';
6
+ /**
7
+ * `fate-core-mods`'s CI smoke-load check (see the registry repo's
8
+ * `validate-pr.yml`) imports a freshly built `bundle.mjs` in plain Node and
9
+ * runs it through the exact same `validateBundleShape` gate the app's real
10
+ * loader uses (`src/mods/loader.ts` in the app repo), then mounts every
11
+ * declared sheet component to catch render-time crashes before a human
12
+ * reviewer ever runs the mod. This file is intentionally NOT imported by
13
+ * `./index.ts` (the build-preset entry) — it pulls in `vue`/`vue-i18n`/
14
+ * `jsdom`/`@vue/test-utils`, none of which a mod author's `vite.config.ts`
15
+ * needs, so it's a separate `@fate-app/mod-build/testing` subpath export.
16
+ *
17
+ * `vue`/`vue-i18n` are deliberately imported *dynamically*, never at module
18
+ * top level: `@vue/runtime-dom` captures a reference to `document` the
19
+ * moment it's first evaluated (`const doc = typeof document !== 'undefined'
20
+ * ? document : null`, module-scope, computed once). A static top-level
21
+ * `import 'vue'` in this file would run before `ensureDom()` ever gets a
22
+ * chance to install jsdom's `document` onto `globalThis`, permanently baking
23
+ * in `doc = null` for this module instance — reassigning `globalThis.document`
24
+ * afterwards doesn't retroactively fix an already-captured reference. Every
25
+ * function here that touches Vue imports it lazily, after `ensureDom()`.
26
+ */
27
+ /** Loose, duck-typed stand-in for the app's real `FateSDK` shape (`src/mods/sdk.ts`)
28
+ * — deliberately not imported from the app, which isn't reachable from here. */
29
+ export interface StubFateSDK {
30
+ version: string;
31
+ vue: typeof VueModule;
32
+ vueI18n: typeof VueI18nModule;
33
+ ionicVue: Record<string, unknown>;
34
+ ionicons: Record<string, unknown>;
35
+ dice: {
36
+ three: typeof ThreeModule;
37
+ cannonEs: typeof CannonEsModule;
38
+ };
39
+ api: {
40
+ toast: {
41
+ error(key: string, opts?: Record<string, unknown>): Promise<void>;
42
+ success(key: string, opts?: Record<string, unknown>): Promise<void>;
43
+ };
44
+ getModData<T>(character: unknown, key: string): T | undefined;
45
+ setModData<T>(character: unknown, key: string, value: T): void;
46
+ };
47
+ }
48
+ export declare function stubFateSDK(): Promise<StubFateSDK>;
49
+ export type SmokeLoadResult = {
50
+ ok: true;
51
+ } | {
52
+ ok: false;
53
+ error: string;
54
+ };
55
+ /**
56
+ * Imports a built `bundle.mjs`, validates its shape, and mounts every
57
+ * declared sheet component. Returns a result instead of throwing so the CI
58
+ * script can print a clean pass/fail per mod rather than a raw stack trace.
59
+ */
60
+ export declare function smokeLoad(bundlePath: string, manifest: {
61
+ capabilities?: FateModCapability[];
62
+ }): Promise<SmokeLoadResult>;
@@ -0,0 +1,183 @@
1
+ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
2
+ if (typeof path === "string" && /^\.\.?\//.test(path)) {
3
+ return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
4
+ return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
5
+ });
6
+ }
7
+ return path;
8
+ };
9
+ import { pathToFileURL } from 'node:url';
10
+ import { resolve } from 'node:path';
11
+ import { validateBundleShape, DiceMaterial } from '@fate-app/mod-types';
12
+ /**
13
+ * Every property access on the returned object resolves to *something* valid
14
+ * (a trivial passthrough component, or the property name itself for icons)
15
+ * instead of `undefined` — real `@ionic/vue`/`ionicons` Web Components aren't
16
+ * registered in a plain Node process, and failing a smoke test purely because
17
+ * of that would be a false positive. Genuine bugs in a mod's own render logic
18
+ * still throw normally.
19
+ */
20
+ function stubModule(vue, kind) {
21
+ return new Proxy({}, {
22
+ get(_target, prop) {
23
+ if (typeof prop !== 'string')
24
+ return undefined;
25
+ if (kind === 'icon')
26
+ return prop;
27
+ return vue.defineComponent({
28
+ name: `Stub_${prop}`,
29
+ setup(_props, { slots }) {
30
+ return () => vue.h('div', slots.default?.());
31
+ }
32
+ });
33
+ }
34
+ });
35
+ }
36
+ export async function stubFateSDK() {
37
+ const vue = await import('vue');
38
+ const vueI18n = await import('vue-i18n');
39
+ const three = await import('three');
40
+ const cannonEs = await import('cannon-es');
41
+ return {
42
+ version: '0.0.0-smoke-test',
43
+ vue,
44
+ vueI18n,
45
+ ionicVue: stubModule(vue, 'component'),
46
+ ionicons: stubModule(vue, 'icon'),
47
+ dice: { three, cannonEs },
48
+ api: {
49
+ toast: { error: async () => { }, success: async () => { } },
50
+ getModData: () => undefined,
51
+ setModData: () => { }
52
+ }
53
+ };
54
+ }
55
+ function stubContext() {
56
+ return { modules: {}, constants: {}, templates: { character: {} }, shared: {}, components: [] };
57
+ }
58
+ /** Sheet components receive the character as `defineModel<Character>({ required: true })`
59
+ * — a real mod legitimately expects a non-empty `modelValue` prop, so mounting
60
+ * without one is a smoke-load false positive/negative, not a real bug to catch. */
61
+ function stubCharacter() {
62
+ return { id: 0, name: 'Smoke Test', avatar: '', _modules: {} };
63
+ }
64
+ /**
65
+ * Constructor-level check for a dice-capability bundle: instantiates each
66
+ * declared shape with a real (headless) three/cannon-es material and physics
67
+ * world, catching crashes in createMesh()/createBody(). Deliberately shallow
68
+ * — it never renders (no WebGLRenderer, no canvas) or steps physics; real
69
+ * visual/gameplay behavior is reviewed by a human, same as sheet components
70
+ * are only mounted here, not interacted with.
71
+ */
72
+ async function smokeLoadDice(dice) {
73
+ const three = await import('three');
74
+ const cannonEs = await import('cannon-es');
75
+ for (const material of (dice.materials ?? [])) {
76
+ if (typeof material.name !== 'string' || typeof material.previewColor !== 'string' || !material.faceMaterial || !material.symbolMaterial) {
77
+ throw new Error(`Invalid dice material shape: ${JSON.stringify(Object.keys(material))}`);
78
+ }
79
+ }
80
+ const stubMaterial = new DiceMaterial('smoke-test', new three.MeshStandardMaterial(), new three.MeshStandardMaterial(), '#ffffff');
81
+ const world = new cannonEs.World();
82
+ for (const Shape of (dice.shapes ?? [])) {
83
+ const instance = new Shape(stubMaterial, 1, 1, 1, world, () => { });
84
+ instance.getResult();
85
+ instance.formatResult(0);
86
+ }
87
+ }
88
+ // Vue's runtime-dom + Ionic-style components reach for a range of DOM
89
+ // constructors, not just document/navigator. Deliberately a curated list,
90
+ // not "copy every jsdom window property" — jsdom's window has circular
91
+ // self-references (window.self/top/parent all alias back to window itself),
92
+ // and eagerly reading those as plain values produces a genuinely circular
93
+ // object graph that overflows the stack the moment anything tries to
94
+ // traverse it (Vue's reactivity, console.log, etc.).
95
+ const DOM_GLOBALS = [
96
+ 'document',
97
+ 'navigator',
98
+ 'Element',
99
+ 'HTMLElement',
100
+ 'SVGElement',
101
+ 'Node',
102
+ 'Text',
103
+ 'Comment',
104
+ 'DocumentFragment',
105
+ 'Event',
106
+ 'CustomEvent',
107
+ 'MouseEvent',
108
+ 'KeyboardEvent',
109
+ 'MutationObserver',
110
+ 'customElements',
111
+ 'getComputedStyle',
112
+ 'requestAnimationFrame',
113
+ 'cancelAnimationFrame'
114
+ ];
115
+ /**
116
+ * `@vue/test-utils`'s `mount()` needs `document`/`window` — present under a
117
+ * test runner's own environment (Vitest/Jest), but not in a bare `node
118
+ * scripts/ci/smoke-load.ts` process. Bootstraps a minimal jsdom window onto
119
+ * `globalThis` the first time it's needed so `smokeLoad()` works standalone;
120
+ * a no-op if a DOM global already exists (e.g. this ever runs inside Vitest).
121
+ * Must run — and complete — before `vue` is imported anywhere (see the
122
+ * module-level comment above).
123
+ */
124
+ async function ensureDom() {
125
+ if (typeof document !== 'undefined')
126
+ return;
127
+ const { JSDOM } = await import('jsdom');
128
+ const dom = new JSDOM('<!doctype html><html><body></body></html>');
129
+ const globalTarget = globalThis;
130
+ const domWindow = dom.window;
131
+ globalTarget.window = dom.window;
132
+ for (const key of DOM_GLOBALS) {
133
+ // Node defines a few of these itself (e.g. `navigator`, read-only since
134
+ // Node 21) — defineProperty with configurable:true overrides those too.
135
+ Object.defineProperty(globalTarget, key, { value: domWindow[key], configurable: true, writable: true });
136
+ }
137
+ }
138
+ /**
139
+ * Imports a built `bundle.mjs`, validates its shape, and mounts every
140
+ * declared sheet component. Returns a result instead of throwing so the CI
141
+ * script can print a clean pass/fail per mod rather than a raw stack trace.
142
+ */
143
+ export async function smokeLoad(bundlePath, manifest) {
144
+ await ensureDom();
145
+ const globalTarget = globalThis;
146
+ const previousSDK = globalTarget.FateSDK;
147
+ globalTarget.FateSDK = await stubFateSDK();
148
+ try {
149
+ const imported = (await import(__rewriteRelativeImportExtension(pathToFileURL(resolve(bundlePath)).href)));
150
+ const bundle = imported.default;
151
+ validateBundleShape(bundle, manifest.capabilities);
152
+ if (manifest.capabilities?.includes('sheetComponents') && Array.isArray(bundle.components)) {
153
+ const { mount } = await import('@vue/test-utils');
154
+ for (const entry of bundle.components) {
155
+ // The component's real prop shape isn't known statically (it comes
156
+ // from a dynamically imported, untyped bundle) — `as never` opts out
157
+ // of mount()'s prop-shape checking for both arguments deliberately,
158
+ // not just the component itself.
159
+ mount(entry.component, {
160
+ props: { modelValue: stubCharacter() },
161
+ global: {
162
+ provide: { context: stubContext() },
163
+ // Real mods use $t() in templates via the host's real vue-i18n
164
+ // instance (FateSDK.vueI18n) — no i18n plugin is installed here,
165
+ // so stub $t as an identity function rather than failing a
166
+ // legitimate call with "$t is not a function".
167
+ mocks: { $t: (key) => key }
168
+ }
169
+ });
170
+ }
171
+ }
172
+ if (manifest.capabilities?.includes('dice') && bundle.dice) {
173
+ await smokeLoadDice(bundle.dice);
174
+ }
175
+ return { ok: true };
176
+ }
177
+ catch (e) {
178
+ return { ok: false, error: e instanceof Error ? e.message : String(e) };
179
+ }
180
+ finally {
181
+ globalTarget.FateSDK = previousSDK;
182
+ }
183
+ }
package/package.json ADDED
@@ -0,0 +1,70 @@
1
+ {
2
+ "name": "@fate-app/mod-build",
3
+ "version": "2.0.0",
4
+ "description": "Vite build preset + dev-mode server for authoring Assistant for Fate mods — externalizes host-shared libraries against window.FateSDK, inlines CSS, and enforces bundle size limits.",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/Stanislavsonder/fate.git",
9
+ "directory": "packages/mod-build"
10
+ },
11
+ "type": "module",
12
+ "types": "./dist/index.d.ts",
13
+ "bin": {
14
+ "fate-mod-build": "./dist/cli.js"
15
+ },
16
+ "exports": {
17
+ ".": {
18
+ "types": "./dist/index.d.ts",
19
+ "import": "./dist/index.js"
20
+ },
21
+ "./testing": {
22
+ "types": "./dist/testing.d.ts",
23
+ "import": "./dist/testing.js"
24
+ }
25
+ },
26
+ "files": [
27
+ "dist"
28
+ ],
29
+ "publishConfig": {
30
+ "access": "public"
31
+ },
32
+ "dependencies": {
33
+ "@fate-app/mod-types": "^2.0.0",
34
+ "vite-plugin-css-injected-by-js": "^3.5.2"
35
+ },
36
+ "devDependencies": {
37
+ "@types/jsdom": "^21.1.0",
38
+ "cannon-es": "^0.20.0",
39
+ "es-module-lexer": "^2.3.1",
40
+ "jsdom": "^29.1.1",
41
+ "three": "^0.185.0"
42
+ },
43
+ "peerDependencies": {
44
+ "@vitejs/plugin-vue": "^6.0.0",
45
+ "@vue/test-utils": "^2.4.0",
46
+ "cannon-es": "^0.20.0",
47
+ "jsdom": "^25.0.0 || ^26.0.0 || ^27.0.0 || ^28.0.0 || ^29.0.0",
48
+ "three": "^0.185.0",
49
+ "vite": "^8.0.0",
50
+ "vue": "^3.5.0"
51
+ },
52
+ "peerDependenciesMeta": {
53
+ "@vue/test-utils": {
54
+ "optional": true
55
+ },
56
+ "cannon-es": {
57
+ "optional": true
58
+ },
59
+ "jsdom": {
60
+ "optional": true
61
+ },
62
+ "three": {
63
+ "optional": true
64
+ }
65
+ },
66
+ "scripts": {
67
+ "build": "tsc -p tsconfig.build.json",
68
+ "generate-sdk-exports": "node --experimental-transform-types scripts/generateSdkExports.ts"
69
+ }
70
+ }