# Mystique SDK File Templates

Companion file for the Mystique SDK Component Scaffolder. Contains all file templates generated during scaffolding.

**Loaded by:** `fluent-mystique-scaffold`

---

## File Templates

### package.json

```json
{
  "name": "${PROJECT_NAME}",
  "version": "1.0.0",
  "description": "Mystique SDK Plugin - ${PROJECT_TITLE}",
  "main": "index.js",
  "scripts": {
    "start": "webpack serve --mode development --open --hot --port 3001",
    "build": "webpack --mode production && jest ./build",
    "bundle": "webpack",
    "test": "jest --coverage --testPathIgnorePatterns /build/",
    "test:bail": "jest --bail --testPathIgnorePatterns /build/",
    "test:storybook": "start-storybook --smoke-test",
    "check-types": "tsc",
    "storybook": "NODE_OPTIONS='--openssl-legacy-provider' start-storybook -p 6006 -s ./lib",
    "storybook:build": "NODE_OPTIONS='--openssl-legacy-provider' build-storybook -o dist/_storybook && cp ./lib/*.js ./dist/_storybook",
    "lint": "eslint src manifests --ext .ts,.tsx --no-error-on-unmatched-pattern",
    "lint:fix": "yarn run lint -- --fix"
  },
  "devDependencies": {
    "@babel/core": "^7.13.8",
    "@emotion/babel-plugin": "^11.11.0",
    "@babel/preset-env": "^7.13.9",
    "@babel/preset-react": "^7.12.10",
    "@babel/preset-typescript": "^7.12.7",
    "@emotion/eslint-plugin": "^11.10.0",
    "@emotion/jest": "^11.10.0",
    "@emotion/react": "^11.10.0",
    "@storybook/addon-actions": "6.5.12",
    "@storybook/addon-essentials": "6.5.12",
    "@storybook/addon-links": "6.5.12",
    "@storybook/addon-storyshots": "6.5.12",
    "@storybook/builder-webpack5": "6.5.12",
    "@storybook/core-common": "6.5.12",
    "@storybook/manager-webpack5": "6.5.12",
    "@storybook/react": "6.5.12",
    "@testing-library/dom": "^7.31.2",
    "@testing-library/jest-dom": "^5.11.9",
    "@testing-library/react": "^11.2.5",
    "@testing-library/react-hooks": "^5.1.1",
    "@testing-library/user-event": "^13.1.9",
    "@types/jest": "^26.0.20",
    "@types/lodash": "^4.14.167",
    "@types/material-ui": "^0.21.8",
    "@types/node": "^18.7.14",
    "@types/react": "^17.0.0",
    "@types/react-dom": "^17.0.0",
    "@types/testing-library__jest-dom": "^5.14.0",
    "@types/webpack": "^5.0.0",
    "@types/webpack-dev-server": "^3.11.3",
    "@typescript-eslint/eslint-plugin": "^4.28.0",
    "@typescript-eslint/parser": "^4.28.0",
    "babel-jest": "^26.6.3",
    "babel-loader": "^8.2.2",
    "copy-webpack-plugin": "^9.0.1",
    "eslint": "^7.29.0",
    "eslint-plugin-no-only-tests": "^2.6.0",
    "eslint-plugin-react": "^7.24.0",
    "eslint-plugin-react-hooks": "^4.2.0",
    "graphql": "^15.4.0",
    "graphql-tag": "^2.12.4",
    "html-webpack-plugin": "^5.3.1",
    "jest": "^26.6.3",
    "jest-fetch-mock": "^3.0.3",
    "jest-html-reporter": "^3.3.0",
    "lodash": "^4.17.21",
    "react-test-renderer": "^17.0.2",
    "ts-jest": "^26.5.2",
    "typescript": "^4.1.3",
    "url-loader": "^4.1.1",
    "webpack": "^5.74.0",
    "webpack-cli": "^4.10.0",
    "webpack-dev-server": "^4.10.0"
  },
  "dependencies": {
    "@material-ui/core": "4.12.1",
    "@material-ui/lab": "4.0.0-alpha.61",
    "@material-ui/pickers": "^3.3.10",
    "qs": "^6.10.1",
    "react": "^17.0.1",
    "react-dom": "^17.0.1"
  },
  "browserslist": {
    "production": [">0.2%", "not dead", "not op_mini all"],
    "development": ["last 1 chrome version", "last 1 firefox version", "last 1 safari version"]
  }
}
```

**Important notes about dependencies:**
- `react`, `react-dom`, and `@material-ui/core` are listed as `dependencies` but are treated as webpack **externals** -- they are NOT bundled into the output. They exist in `dependencies` for TypeScript type resolution and for Storybook/Jest test execution.
- The host Fluent OMX application provides React 17, ReactDOM 17, and Material-UI 4.12 at runtime via `window.React`, `window.ReactDOM`, and `window.MaterialUI` globals.
- Storybook version is pinned to 6.5.12 for compatibility with the Mystique SDK.

### webpack.config.ts

This is the most critical file -- it controls how the bundle is produced and what is externalized.

```typescript
const path = require('path');
import { Configuration } from 'webpack';
import HtmlWebpackPlugin from 'html-webpack-plugin';
const GenerateVersionPlugin = require('./build/GenerateVersionPlugin');
const GenerateManifestsPlugin = require('./build/GenerateManifestsPlugin');
const CopyWebpackPlugin = require('copy-webpack-plugin');

const config: Configuration = {

  // Entry point -- all ComponentRegistry.register() calls happen here
  entry: './src/index',

  devServer: {
    allowedHosts: 'all',
    static: {
      directory: path.resolve(__dirname, 'dist'),
      watch: true,
    },
    headers: {
      'Access-Control-Allow-Origin': '*',
      'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, PATCH, OPTIONS',
      'Access-Control-Allow-Headers': 'X-Requested-With, content-type, Authorization',
      'Access-Control-Allow-Private-Network': 'true',
    },
  },

  // Output: bundle.[contenthash].js for cache busting
  output: {
    path: path.join(__dirname, '/dist'),
    clean: true,
    filename: 'bundle.[contenthash].js',
  },

  resolve: {
    extensions: ['.ts', '.tsx', '.js'],
  },

  module: {
    rules: [
      {
        test: /\.(ts|js)x?$/,
        exclude: /node_modules/,
        use: {
          loader: 'babel-loader',
        },
      },
      {
        test: /\.(png|jp(e*)g|svg)$/,
        use: [{
          loader: 'url-loader',
          options: {
            limit: 8000,
            name: 'images/[hash]-[name].[ext]',
          },
        }],
      },
    ],
  },
  plugins: [
    new HtmlWebpackPlugin({
      template: './src/index.html',
    }),
    new CopyWebpackPlugin({
      patterns: [
        {
          from: 'i18n/locales',
          to: path.join(__dirname, 'dist/i18n'),
        },
      ],
    }),
    new GenerateVersionPlugin(),
    new GenerateManifestsPlugin(),
  ],

  // CRITICAL: externals configuration
  // React, ReactDOM, Emotion, Material-UI, and Mystique are provided by the host app
  // They must NOT be bundled -- the bundle uses window globals at runtime
  externals: [{
    'react': 'React',
    'react-dom': 'ReactDOM',
    '@emotion/react': 'emotionReact',
  },
  externalMaterialUI,
  externalMystique,
  ],
};

/** Re-route @material-ui/core and @material-ui/lab imports to the global UMD import */
export function externalMaterialUI (_: any, module: any, callback: any): any {
  if (module === '@material-ui/core' || module === '@material-ui/lab') {
    return callback(null, `window["MaterialUI"]`);
  }
  const isMaterialUIComponent = /^@material-ui\/(core|lab)\/.+$/;
  const match = isMaterialUIComponent.exec(module);
  if (match !== null) {
    const parts = module.split('/');
    const component = parts[parts.length - 1];
    if (component === 'styles') {
      return callback(null, `window["MaterialUI"]`);
    }
    return callback(null, `window["MaterialUI"].${component}`);
  }
  callback();
}

/** Re-route mystique/* imports to the global Mystique object */
export function externalMystique (_: any, module: any, callback: any): any {
  const isMystiqueComponent = /^mystique\/.+$/;
  const match = isMystiqueComponent.exec(module);
  if (match !== null) {
    return callback(null, `window["Mystique"]`);
  }
  callback();
}

export default config;
```

**Key points about the webpack config:**
- `CORS headers` on devServer are required so the Fluent OMX host app (running on a different port/domain) can load the bundle.
- `contenthash` in the filename enables cache busting in production.
- The `GenerateVersionPlugin` emits a `version.json` that maps the hashed bundle filename, enabling the host app to auto-discover the current bundle.
- The `GenerateManifestsPlugin` compiles `.ts` manifest files under `manifests/` into `.json` in `dist/manifests/`.
- The three external handlers ensure ZERO framework code ends up in the bundle. The host OMX app provides everything via `window` globals.

### tsconfig.json

```json
{
  "compilerOptions": {
    "target": "es6",
    "outDir": "./dist/",
    "strictNullChecks": true,
    "moduleResolution": "node",
    "resolveJsonModule": true,
    "allowJs": true,
    "noEmit": true,
    "strict": true,
    "esModuleInterop": true,
    "jsx": "react-jsx",
    "jsxImportSource": "@emotion/react",
    "baseUrl": "./src",
    "lib": ["es2015", "dom.iterable", "es2016.array.include", "es2017.object", "dom"],
    "types": ["node", "jest", "@testing-library/jest-dom"],
    "typeRoots": ["./node_modules/@types", "./@types/**/*"],
    "module": "es6",
    "removeComments": true,
    "alwaysStrict": true,
    "allowUnreachableCode": false,
    "noImplicitAny": true,
    "noImplicitThis": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "noImplicitReturns": true,
    "noFallthroughCasesInSwitch": true,
    "forceConsistentCasingInFileNames": true,
    "importHelpers": true,
    "skipLibCheck": true
  },
  "include": ["src/**/*", "manifests/**/*", "@types/**/*", "./@types"],
  "exclude": ["node_modules", "./node_modules", "./node_modules/*", "./node_modules/@types/node/index.d.ts"]
}
```

**Key: `jsxImportSource: "@emotion/react"`** enables Emotion CSS-in-JS props on JSX elements. The `typeRoots` includes `./@types/**/*` which is where the Mystique type definitions live.

### @types/mystique-barrel.d.ts

Provide barrel module declarations and a runtime-accurate `useQuery` type:

```typescript
/**
 * Barrel module declarations for mystique/hooks and mystique/registry.
 * The SDK's @types/mystique/ only declares subpath modules (e.g., mystique/hooks/useQuery).
 * This file provides barrel imports used by app code (e.g., import { useQuery } from 'mystique/hooks').
 */
declare module 'mystique/hooks' {
  export { useAuth } from 'mystique/hooks/useAuth';
  export { useEnv } from 'mystique/hooks/useEnv';
  export { useI18n } from 'mystique/hooks/useI18n';
  export { useSettings } from 'mystique/hooks/useSettings';
  export { getQuery } from 'mystique/hooks/getQuery';
  export { useRest } from 'mystique/hooks/useRest';
  export { getRest } from 'mystique/hooks/getRest';
  export { useData } from 'mystique/hooks/useData';
  export { useUserActions } from 'mystique/hooks/useUserActions';
  export { useUserActionForm } from 'mystique/hooks/useUserActionForm';
  export { getApiDownload } from 'mystique/hooks/getApiDownload';
  export { getSettings } from 'mystique/hooks/getSettings';

  /**
   * useQuery returns [UseQueryState<T>, reexecuteFn].
   * UseQueryState = { fetching: boolean; stale: boolean; data?: T; error?: CombinedError }
   * Access: const [result] = useQuery<T>(query, vars); result.fetching, result.data
   */
  export function useQuery<T = any>(
    query: string,
    variables?: Record<string, any>
  ): [{ fetching: boolean; stale: boolean; data?: T; error?: any }, () => void];
}

declare module 'mystique/registry' {
  export { ComponentRegistry, StdProps } from 'mystique/registry/ComponentRegistry';
  export { FieldRegistry, FormFieldProps } from 'mystique/registry/FieldRegistry';
  export { TemplateRegistry } from 'mystique/registry/TemplateRegistry';
}

declare module 'mystique/components' {
  export { Card } from 'mystique/components/Card';
  export { CardContent } from 'mystique/components/CardContent';
  export { Children } from 'mystique/components/Children';
  export { DynamicValue } from 'mystique/components/DynamicValue';
  export { Loading } from 'mystique/components/Loading';
}
```

### jest.config.js

```javascript
const path = require('path');

module.exports = {
    restoreMocks: true,
    roots: [
        "<rootDir>"
    ],
    setupFilesAfterEnv: ['./src/setup-tests.ts'],
    setupFiles: [
        "./lib/material-ui.js",
        "./lib/init.js",
        "./lib/mystique.js",
    ],
    testMatch: [
        "**/?(*.)+(test).+(ts|tsx)"
    ],
    coverageThreshold: {
        global: {
            statements: 80,
            branches: 80,
            functions: 80,
            lines: 80
        }
    },
    coveragePathIgnorePatterns: [
        ".storybook/",
        "src/index.tsx",
        '.stories.'
    ],
    collectCoverageFrom: [
        "src/**/*.{ts,tsx}"
    ],
    preset: 'ts-jest',
    transform: {
        '^.+\\.(ts|tsx)?$': 'ts-jest',
        "^.+\\.(js|jsx)$": "babel-jest",
    },
    moduleNameMapper: {
        "\\.(jpg|ico|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$": "identity-obj-proxy",
        "\\.(css|less)$": "identity-obj-proxy",
        "^./react": path.resolve(__dirname, 'node_modules/react'),
        "^mystique": path.resolve(__dirname, 'lib/mystique-export.js')
    },
    reporters: [
        "default",
        ["./node_modules/jest-html-reporter", {
            "pageTitle": "Test Report",
            "outputPath": "./test-report/test-report.html"
        }]
    ],
};
```

**Critical: `setupFiles`** loads `lib/material-ui.js`, `lib/init.js`, and `lib/mystique.js` before tests run. These files set up the global objects (`window.React`, `window.MaterialUI`, `window.Mystique`) that the webpack externals resolve to at runtime. The `moduleNameMapper` for `^mystique` redirects Mystique module imports to `lib/mystique-export.js` which provides named exports from the global Mystique object.

### .babelrc

```json
{
  "presets": [
    "@babel/preset-env",
    "@babel/preset-typescript",
    [
      "@babel/preset-react",
      {
        "runtime": "automatic",
        "importSource": "@emotion/react"
      }
    ]
  ],
  "plugins": ["@emotion/babel-plugin"]
}
```

### lib/init.js

Sets up global objects that the webpack externals resolve to. Required for both Jest tests and the dev server HTML page.

```javascript
import fetchMock from 'jest-fetch-mock';
fetchMock.enableMocks();

import React from 'react';
globalThis.React = React;

import ReactDOM from 'react-dom';
globalThis.ReactDOM = ReactDOM;

import * as MaterialUI from '@material-ui/core';
globalThis.MaterialUI = MaterialUI;

import * as Emotion from '@emotion/react';
globalThis.emotionReact = Emotion;
```

### lib/mystique-export.js

Provides named exports from the global `Mystique` object. This file is what `moduleNameMapper` in Jest points `mystique/*` imports to, and what makes hook/component imports work in tests.

```javascript
const m = globalThis.Mystique;

// registry
export const ComponentRegistry = m.ComponentRegistry;
export const FieldRegistry = m.FieldRegistry;

// frame
export const DataModal = m.DataModal;
export const pushModal = m.pushModal;
export const pushDrawer = m.pushDrawer;
export const pushToast = m.pushToast;

// hooks
export const useAuth = m.useAuth;
export const useI18n = m.useI18n;
export const useSettings = m.useSettings;
export const useQuery = m.useQuery;
export const useMutation = m.useMutation;
export const useRest = m.useRest;
export const getQuery = m.getQuery;
export const getRest = m.getRest;

// components
export const Card = m.Card;
export const CardContent = m.CardContent;
export const Children = m.Children;
export const DynamicValue = m.DynamicValue;
export const Loading = m.Loading;
export const QuantitySelectorComponent = m.QuantitySelectorComponent;
export const QuantityList = m.QuantityList;

// utils
export const decorateQueryResult = m.decorateQueryResult;
```

### lib/mystique.js and lib/material-ui.js

These are large bundled files from the Fluent Commerce SDK distribution. They set up `globalThis.Mystique` and `globalThis.MaterialUI` respectively. **Do NOT generate these files** -- they must be copied from the extracted SDK directory at `omx-component-sdk/lib/`.

Before downloading anything, ask the user if they already have a component SDK source repo/folder to extend. If they do, use that source directly. If they do not, download the latest SDK zip and extract it in the workspace, then copy from `omx-component-sdk/lib/`.

```
WARNING: lib/mystique.js and lib/material-ui.js are required but cannot be auto-generated.
These files must be obtained from the Fluent Commerce OMX Component SDK distribution.
Download URL: https://fluent.apps.fluentcommerce.com/_sdk/latest/omx-component-sdk.zip
Extract to a local `omx-component-sdk/` folder, then copy from `omx-component-sdk/lib/`.
Without these files, Jest tests and Storybook will fail (missing global Mystique/MaterialUI objects).
```

### src/index.tsx (Entry Point)

This is the entry point for the webpack bundle. All component registrations happen here.

```tsx
import { ComponentRegistry } from 'mystique/registry/ComponentRegistry';
// Import each component
import { ${ComponentName} } from './components/${ComponentName}/${ComponentName}';

// Register with Mystique -- aliases are how the manifest references this component
// Convention: <namespace>.<category>.<kebab-name>
ComponentRegistry.register(
  ['${NAMESPACE}.${CATEGORY}.${KEBAB_NAME}'],
  ${ComponentName},
  { category: '${CATEGORY}' }
);

console.groupCollapsed('Registered ${NAMESPACE} components');
console.log('Components:', ['${NAMESPACE}.${CATEGORY}.${KEBAB_NAME}']);
console.groupEnd();
```

**Alias naming convention:** `<namespace>.<category>.<component-name>` where:
- `namespace` is the company/project prefix (e.g., `acme`, `hm`, `curbside`)
- `category` is one of `page`, `layout`, `content`, `filter`
- `component-name` is kebab-case (e.g., `order-tracker`, `store-map`)

Multiple aliases can be registered for the same component for backward compatibility.

### src/index.html (Dev Server)

```html
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>${PROJECT_TITLE} - Mystique Plugin</title>
    <link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons" />
    <script src="https://cdnjs.cloudflare.com/ajax/libs/react/17.0.1/umd/react.production.min.js"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/17.0.1/umd/react-dom.production.min.js"></script>
    <style>
        body { display:flex; flex-direction: column; align-items: center; justify-content: center; background-color:#efefef; font-family: 'Lato', sans-serif; }
        #root { width: 600px; max-width:90%; padding: 4em 2em 2em 2em; }
    </style>
</head>
<body>
    <div id="root">
        <p>Welcome to the ${PROJECT_TITLE} Mystique Plugin!</p>
        <p>Add this plugin to your Fluent OMX app manifest:</p>
        <pre>
{
  "plugins": [
    { "type": "url", "src": "http://localhost:3001/" }
  ]
}
        </pre>
    </div>
</body>
</html>
```

### src/setup-tests.ts

```typescript
import '@testing-library/jest-dom';
import { matchers } from '@emotion/jest';

expect.extend(matchers);
```

### src/utils/TestUtils.tsx

Provides consistent Material-UI classname generation for Storyshots/snapshot tests plus reusable Mystique hook mock helpers.

```tsx
import { FC } from 'react';
import { StylesProvider, StylesOptions } from '@material-ui/styles';

const generateClassName: StylesOptions['generateClassName'] = (
  rule,
  sheet,
): string => `${sheet!.options.classNamePrefix}-${rule.key}`;

/**
 * Provides consistent classname generation for MaterialUI components to avoid inconsistent snapshots.
 */
export const FixMuiClassnames: FC = ({ children }) => {
  return (
    <StylesProvider generateClassName={generateClassName}>
      {children}
    </StylesProvider>
  );
};

// === Hook Mock Utilities ===
// Use in test files: import { mockUseQuery, mockUseSetting, mockI18nProvider } from '../src/utils/TestUtils';

import * as I18n from 'mystique/hooks/useI18n';
import * as Query from 'mystique/hooks/useQuery';
import * as Setting from 'mystique/hooks/useSettings';
import * as Auth from 'mystique/hooks/useAuth';
import * as Data from 'mystique/hooks/useData';

/**
 * Mock useQuery with typed data. Returns [UseQueryState<T>, reexecuteFn].
 */
export const mockUseQuery = (status: 'ok' | 'loading' | 'error', data?: unknown): void => {
  jest.spyOn(Query, 'useQuery').mockImplementation(() => {
    switch (status) {
      case 'loading':
        return [{ error: undefined, fetching: true, stale: false, data: undefined }, jest.fn()];
      case 'error':
        return [{ error: new Error('Query failed'), fetching: false, stale: false, data: undefined }, jest.fn()];
      case 'ok':
      default:
        return [{ error: undefined, fetching: false, stale: false, data }, jest.fn()];
    }
  });
};

/**
 * Mock multiple useQuery calls in order for components that execute more than one query.
 */
export const mockUseQuerySequence = (...responses: unknown[]): void => {
  const spy = jest.spyOn(Query, 'useQuery');
  responses.forEach((response) => {
    spy.mockImplementationOnce(() => response as any);
  });
};

/**
 * Mock useSettings with status + result shape.
 */
export const mockUseSetting = (
  status: 'ok' | 'loading' | 'error',
  name: string,
  value?: unknown,
): void => {
  if (status === 'error') {
    jest.spyOn(Setting, 'useSettings').mockReturnValue({
      [name]: { status: 'error', result: 'Setting not found' },
    });
  } else if (status === 'loading') {
    jest.spyOn(Setting, 'useSettings').mockReturnValue({
      [name]: { status: 'loading' },
    });
  } else {
    jest.spyOn(Setting, 'useSettings').mockReturnValue({
      [name]: { status: 'ok', result: { name, value } },
    });
  }
};

/**
 * Mock useData for ComponentRegistry tests that rely on page-query context.
 */
export const mockUseData = (data: unknown = {}, variables: Record<string, unknown> = {}): void => {
  jest.spyOn(Data, 'useData').mockReturnValue({
    context: { data, dataSource: [] },
    variables: {
      current: variables,
      available: [],
      setVariables: jest.fn(),
    },
    loadingState: 'ready',
  } as any);
};

/**
 * Mock useI18n — translate returns key, translateOr returns last fallback.
 */
export const mockI18nProvider = (): void => {
  jest.spyOn(I18n, 'useI18n').mockImplementation(() => ({
    translate: (s: string) => s,
    translateOr: (s: unknown) => (Array.isArray(s) ? s[s.length - 1] : s),
    prefixedTranslate: (s: unknown) => (Array.isArray(s) ? s[s.length - 1] : s),
    language: {
      current: 'en',
      available: [],
      setLanguage: () => null,
    },
  }) as any);
};

/**
 * Mock useAuth with configurable user and context.
 */
export const mockUseAuth = (overrides?: {
  username?: string;
  roles?: string[];
  contextId?: string;
  locationRef?: string;
}): void => {
  jest.spyOn(Auth, 'useAuth').mockReturnValue({
    loggedIn: true,
    user: {
      username: overrides?.username || 'test-user',
      firstName: 'Test',
      lastName: 'User',
      timezone: 'UTC',
      language: 'en',
      roles: (overrides?.roles || ['DEVELOPER']).map(r => ({ role: { name: r } })),
    },
    context: {
      current: {
        contextId: overrides?.contextId || '1',
        contextType: 'RETAILER',
        details: { ref: overrides?.locationRef || 'LOC-001' },
      },
      available: [],
      set: jest.fn(),
      roleCheck: (roles: string[]) => ({
        hasRole: roles.some(r => (overrides?.roles || ['DEVELOPER']).includes(r)),
      }),
    },
    logout: jest.fn(),
    switchContext: jest.fn(),
  } as any);
};
```

### src/utils/GqlUtils.ts

```typescript
import { DocumentNode } from 'graphql';
import { print } from 'graphql/language/printer';

/**
 * Converts a GraphQL document node back to a query string.
 * Useful for defining queries with gql`` template literals in manifests
 * and converting them to strings for the manifest JSON output.
 */
export const parse = (doc: DocumentNode) => print(doc);
```

## Shared Utilities (generated for 3+ component projects)

### src/shared/utils/extractEdgeNodes.ts

```typescript
/**
 * Extract node objects from a Relay-style connection response.
 * Handles null/undefined connections gracefully.
 *
 * @example
 * const items = extractEdgeNodes(data?.orderById?.items);
 * // items: OrderItem[] (or empty array if connection is null/undefined)
 */
export function extractEdgeNodes<T>(connection: { edges?: Array<{ node: T }> } | null | undefined): T[] {
  if (!connection?.edges) return [];
  return connection.edges.map(edge => edge.node);
}
```

### src/types/index.ts

```typescript
/**
 * Shared type definitions across components.
 * Import these instead of re-declaring in each component.
 */

/** Standard Relay connection shape from Fluent GraphQL */
export interface RelayConnection<T> {
  edges: Array<{ cursor?: string; node: T }>;
  pageInfo?: {
    hasNextPage: boolean;
  };
}
```

## Component Template

For each component in `--components`, generate the following files:

### Component File (`src/components/<Name>/<Name>.tsx`)

**CRITICAL: `content` category components MUST use `<Card>` as root element.** Card handles MUI Grid item wrapping internally — without it, the component gets 0px/1px width inside `fc.page.section`. Always accept `title` and `width` props and pass them to Card. Skip `<CardContent>` for edge-to-edge content (maps, tables, iframes).

```tsx
import React from 'react';
import { Card, CardWidth } from 'mystique/components/Card';
import { CardContent } from 'mystique/components/CardContent';
import { Loading } from 'mystique/components/Loading';
import { useI18n } from 'mystique/hooks/useI18n';
import { StdProps } from 'mystique/registry/ComponentRegistry';

/**
 * Props for the ${Name} component.
 * Extends StdProps to receive injected data from the page query via dataSource.
 */
export interface ${Name}Props extends StdProps {
  /** Card title displayed in the header. */
  title?: string;
  /** Card width on the 12-column grid (e.g. "full", "half", 6). */
  width?: CardWidth;
  // TODO: Define component-specific props
}

/**
 * ${Name} -- a custom Mystique component.
 *
 * Usage in manifest:
 *   { "component": "${NAMESPACE}.${CATEGORY}.${KEBAB_NAME}", "width": "full", "props": { "title": "My Title" } }
 */
export const ${Name}: React.FC<${Name}Props> = ({ data, title, width, ...props }) => {
  const { translateOr } = useI18n();

  // Loading guard
  if (!data) {
    return <div style={{ minHeight: '4em' }}><Loading /></div>;
  }

  return (
    <Card title={title || translateOr(['${NAMESPACE}.${KEBAB_NAME}.title', '${Name}'])} width={width}>
      <CardContent>
        <div style={{ minHeight: '60px', margin: '8px' }} data-testid="${KEBAB_NAME}-content">
          {/* TODO: Implement component using data from props.data */}
          {translateOr(['${NAMESPACE}.${KEBAB_NAME}.noData', 'No data available'])}
        </div>
      </CardContent>
    </Card>
  );
};
```

### Test File (`src/components/<Name>/<Name>.test.tsx`)

```tsx
import { render, screen } from '@testing-library/react';
import { FixMuiClassnames, mockUseQuery, mockI18nProvider, mockUseAuth } from '../../utils/TestUtils';
import { ${Name} } from './${Name}';

// Mock data matching your component's query response
const mockData = {
  orderById: { id: '123', ref: 'TEST-001', status: 'CREATED' },
};

describe('${Name}', () => {
  beforeEach(() => {
    mockI18nProvider();
    mockUseAuth();
  });

  afterEach(() => jest.restoreAllMocks());

  it('shows loading state', () => {
    mockUseQuery('loading');
    render(<FixMuiClassnames><${Name} /></FixMuiClassnames>);
    // Loading component should be present
  });

  it('renders data when loaded', () => {
    mockUseQuery('ok', mockData);
    render(<FixMuiClassnames><${Name} /></FixMuiClassnames>);
    expect(screen.getByTestId('${KEBAB_NAME}-content')).toBeInTheDocument();
  });

  it('handles error state', () => {
    mockUseQuery('error');
    render(<FixMuiClassnames><${Name} /></FixMuiClassnames>);
    // Error display should be present
  });
});
```

### Story File (`src/components/<Name>/<Name>.stories.tsx`)

```tsx
import { ComponentMeta } from '@storybook/react';
import { storyTemplate } from '../../../.storybook/helpers';
import { ${Name} } from './${Name}';

export default {
  title: 'Components/${Name}',
  component: ${Name},
} as ComponentMeta<typeof ${Name}>;

const template = storyTemplate(${Name});

export const Default = template({
  title: '${Name} Component',
});

export const WithData = template({
  title: '${Name} with Data',
  data: {
    id: '1',
    ref: 'EXAMPLE-001',
    status: 'ACTIVE',
  },
});
```

### Index File (`src/components/<Name>/index.ts`)

```typescript
export { ${Name} } from './${Name}';
export type { ${Name}Props } from './${Name}';
```

## Storybook Configuration

### .storybook/main.ts

```typescript
import { StorybookConfig } from '@storybook/core-common';
import { externalMaterialUI, externalMystique } from '../webpack.config';

const config: StorybookConfig = {
  webpackFinal: async (config: any) => {
    // Do NOT externalize react/react-dom — Storybook needs them bundled.
    // Production webpack.config externalizes all three (host app provides globals).
    config.externals = [
      externalMaterialUI,
      externalMystique
    ];
    return config;
  },
  stories: [
    '../src/**/*.stories.mdx',
    '../src/**/*.stories.@(ts|tsx)'
  ],
  addons: [
    '@storybook/addon-links',
    '@storybook/addon-essentials'
  ],
  core: {
    builder: 'webpack5'
  }
};

module.exports = config;
```

### .storybook/preview.js

```javascript
import { INITIAL_VIEWPORTS } from '@storybook/addon-viewport';
import { ThemeProvider } from '@material-ui/core/styles';
import { FixMuiClassnames } from '../src/utils/TestUtils';

export const parameters = {
  actions: { argTypesRegex: "^on[A-Z].*" },
  viewport: {
    defaultViewport: 'iphone5',
    viewports: INITIAL_VIEWPORTS
  },
};

// FluentThemeProvider — provides palette.mystique.colours in Storybook
// The Mystique host injects this at runtime; replicate it for local dev.
const fluentTheme = {
  palette: {
    mystique: {
      colours: {
        constants: { white: '#FFFFFF', black: '#000000' },
        structure: { background: '#F5F5F5', surface: '#FFFFFF', border: '#E0E0E0' },
        compliance: { success: '#4CAF50', warning: '#FF9800', error: '#F44336', info: '#2196F3' },
      },
    },
  },
};

export const decorators = [
  (Story) => (
    <ThemeProvider theme={(outerTheme) => ({ ...outerTheme, ...fluentTheme })}>
      <FixMuiClassnames>
        <Story />
      </FixMuiClassnames>
    </ThemeProvider>
  )
];
```

### .storybook/Storyshots.test.ts

Use Storyshots for selective visual regression coverage. This is the OOTB snapshot pattern; `react-test-renderer` is present to support this addon, not as the default component test API.

```typescript
import initStoryshots from '@storybook/addon-storyshots';

initStoryshots({
  suite: 'Storyshots',
  storyNameRegex: /Default|Empty|Error|Disabled|EdgeCase/i,
});
```

### .storybook/helpers.ts

```typescript
import { ComponentStory, Story } from '@storybook/react';

export const storyTemplate = <P,>(Component: (props: P) => any) => (
  props: P
): Story<P> => {
  const template: ComponentStory<typeof Component> = (args) => Component(args);
  const story = template.bind({});
  story.args = props;
  return story;
};
```

## Build Plugins

### build/GenerateVersionPlugin.ts

Emits `version.json` alongside the bundle, mapping the hashed filename:

```typescript
import { Compiler, sources } from 'webpack';

class GenerateVersionPlugin {
  apply(compiler: Compiler) {
    compiler.hooks.afterCompile.tap('GenerateVersionPlugin', (compilation) => {
      Object.entries(compilation.assets).forEach(([pathname]) => {
        if (pathname.match(/bundle\.[a-z0-9]+\.js$/))
          compilation.assets['version.json'] =
              new sources.RawSource(JSON.stringify({
                bundle: pathname,
                version: process.env.GIT_TAG || Date.now().toString()
              }));
      });
    });
  }
}

module.exports = GenerateVersionPlugin;
```

### build/GenerateManifestsPlugin.ts

Compiles TypeScript manifest files in `manifests/` to JSON in `dist/manifests/`:

```typescript
import webpack from 'webpack';
const fs = require('fs-extra');

class GenerateManifestsPlugin {
  apply(compiler: webpack.Compiler) {
    compiler.hooks.afterEmit.tap('GenerateManifestsPlugin', () => {
      fs.readdir('./manifests', (err: string, files: string[]) => {
        if (err) {
          console.log(err);
        } else {
          files.forEach((file: string) => {
            if (file.includes('fc.mystique.manifest') && !file.includes('.test.')) {
              const filenameNoExt = file.slice(0, -3);
              import(`../manifests/${filenameNoExt}`).then(manifest => {
                fs.outputFile(
                  `dist/manifests/${filenameNoExt}.json`,
                  JSON.stringify(manifest.manifest, null, 4)
                );
              });
            }
          });
        }
      });
    });
  }
}

module.exports = GenerateManifestsPlugin;
```

### build/Build.test.ts

Validates the production build output. Runs automatically via `yarn build` (the build script includes `&& jest ./build`).

```typescript
import * as fs from 'fs';
import * as path from 'path';

const distDir = path.resolve(__dirname, '../dist');

describe('Build output', () => {
  it('produces a hashed bundle file', () => {
    const files = fs.readdirSync(distDir);
    const bundle = files.find(f => /^bundle\.[a-f0-9]+\.js$/.test(f));
    expect(bundle).toBeDefined();
  });

  it('produces version.json with bundle reference', () => {
    const versionPath = path.join(distDir, 'version.json');
    expect(fs.existsSync(versionPath)).toBe(true);
    const version = JSON.parse(fs.readFileSync(versionPath, 'utf-8'));
    expect(version.bundle).toMatch(/^bundle\.[a-f0-9]+\.js$/);
  });

  it('produces manifest JSON files', () => {
    const manifestDir = path.join(distDir, 'manifests');
    if (fs.existsSync(manifestDir)) {
      const manifests = fs.readdirSync(manifestDir).filter(f => f.endsWith('.json'));
      expect(manifests.length).toBeGreaterThan(0);
      manifests.forEach(m => {
        const content = JSON.parse(fs.readFileSync(path.join(manifestDir, m), 'utf-8'));
        expect(content).toBeDefined();
      });
    }
  });
});
```

**Note:** This test file lives in `build/`, NOT in `src/`. It's excluded from the main test run via `testPathIgnorePatterns: /build/` in jest.config.js and only runs as part of the build command (`yarn build`).

## README Template

Generate this as `README.md` in the project root:

~~~markdown
# {{PROJECT_TITLE}}

{{DESCRIPTION}} — A Fluent Commerce Mystique SDK component plugin.

## Components

| Alias | Category | Description |
|-------|----------|-------------|
{{#each COMPONENTS}}
| `{{NAMESPACE}}.{{CATEGORY}}.{{KEBAB_NAME}}` | {{CATEGORY}} | TODO: Add description |
{{/each}}

## Prerequisites

- Node.js 16-18
- Yarn 1.x
- Fluent Commerce account with OMS App access

## Development

```bash
yarn install          # Install dependencies
yarn start            # Dev server at http://localhost:3001
yarn test             # Jest tests with coverage
yarn storybook        # Component development UI
yarn lint             # ESLint check
yarn lint:fix         # Auto-fix lint issues
```

## Build

```bash
yarn build            # → dist/bundle.[hash].js + dist/version.json
```

## Local Test Loop (Recommended)

1. Start local dev server:
   ```bash
   yarn start
   ```
2. Point manifest `plugins[]` to:
   ```json
   { "type": "url", "src": "http://localhost:3001/" }
   ```
3. Iterate quickly: edit code -> auto-rebuild -> hard refresh UI -> verify again
4. When ready for release: stop dev server, run `yarn build`, upload `dist/`, switch `plugins[]` back to CDN URL

## Configure

Add to your Mystique manifest `plugins[]`:

```json
{
  "plugins": [
    { "type": "url", "src": "http://localhost:3001/" }
  ]
}
```

For production, use your CDN URL:
```json
{ "type": "url", "src": "https://your-cdn.example.com/{{PROJECT_NAME}}/" }
```

Reference components in manifest pages:
```json
{ "component": "{{NAMESPACE}}.{{CATEGORY}}.{{KEBAB_NAME}}" }
```

## Deploy

1. `yarn build` → produces `dist/`
2. Upload `dist/` to CDN (Vercel, S3, CloudFront)
3. Set short TTL on `version.json`, long TTL on bundle
4. Update manifest `plugins[]` with CDN URL (trailing `/` required)
5. Deploy manifest setting (`valueType: JSON`, `context: ACCOUNT`, `contextId: 0`)
6. Verify in browser
7. Confirm deployed manifest does not reference `http://localhost:3001/`

## Architecture

- **Stack:** React 17, TypeScript, Material-UI 4.12, Webpack 5
- **Externals:** React, ReactDOM, MUI provided by host app at runtime
- **Registration:** `src/index.tsx` → `ComponentRegistry.register()`
- **Build output:** Single hashed bundle loaded via manifest `plugins[]`
~~~

## .gitignore

```gitignore
# Dependencies
node_modules/

# Build output
dist/

# Test output
test-report/
coverage/

# IDE files
.idea/
*.iml
.vscode/

# OS files
.DS_Store
Thumbs.db

# Logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
```

## i18n Locale Files

### i18n/locales/en/translation.json

Scaffold a minimal English translation file. OOTB plugins use flat JSON with hierarchical dot-separated keys.

```json
{
  "${NAMESPACE}.${APP_NAME}.title": "${PROJECT_TITLE}",
  "${NAMESPACE}.${APP_NAME}.loading": "Loading...",
  "${NAMESPACE}.${APP_NAME}.error": "Something went wrong",
  "${NAMESPACE}.${APP_NAME}.noData": "No data available"
}
```

**i18n key naming convention (from OOTB plugins):**
- Pattern: `<namespace>.<app>.[ui.]<domain>.<path>.<type>`
- Examples: `fc.oms.orders.list.title`, `fc.store.ui.pick.item.scanned.label`
- Custom plugins: replace `fc` with your namespace — e.g., `acme.oms.orders.filter.status`
- Keys are flat (no nesting), dot-separated hierarchy is by convention only
- Use `i18n:` prefix in manifests for static labels: `"label": "i18n:acme.oms.nav.orders"`
- Use `{{i18n 'key'}}` in templates for dynamic interpolation
- Use `translateOr(['primary.key', 'Fallback text'])` in SDK components for graceful degradation

## Template Helper Templates

### Template Helper File — src/helpers/<Name>.ts

```typescript
/**
 * {{HELPER_NAME}} — Custom Handlebars template helpers
 *
 * Usage in manifest: {{<namespace>.helpers.<kebab-name> param1 param2}}
 * Registration: TemplateRegistry.register('<namespace>.helpers.<kebab-name>', helperFn)
 *
 * Handlebars helper signature:
 *   (arg1, arg2, ..., options: Handlebars.HelperOptions) => string
 *
 * The last argument is always the Handlebars `options` object containing:
 *   - options.hash    — named params: {{helper key="value"}} → options.hash.key
 *   - options.fn      — block content renderer (block helpers only)
 *   - options.inverse  — else-block renderer (block helpers only)
 *   - options.data    — template data (@root, @index, etc.)
 */
import { TemplateRegistry } from 'mystique/registry/TemplateRegistry';

// ---------------------------------------------------------------------------
// 1. formatCurrency — Format a numeric amount as a localised currency string
// ---------------------------------------------------------------------------
/**
 * Usage: {{acme.helpers.format-currency 99.99 "AUD"}}
 * Named-param variant: {{acme.helpers.format-currency amount locale="de-DE"}}
 */
export const formatCurrency = (
  amount: number,
  currencyCode: string,
  options?: { hash?: { locale?: string } },
): string => {
  const locale = options?.hash?.locale || 'en-US';
  try {
    return new Intl.NumberFormat(locale, {
      style: 'currency',
      currency: currencyCode,
    }).format(amount);
  } catch {
    return `${currencyCode} ${amount}`;
  }
};

// ---------------------------------------------------------------------------
// 2. formatDate — Format an ISO date string for display
// ---------------------------------------------------------------------------
/**
 * Usage: {{acme.helpers.format-date "2026-03-01T10:30:00Z"}}
 * Named-param variant: {{acme.helpers.format-date isoString locale="en-AU" style="long"}}
 */
export const formatDate = (
  isoString: string,
  options?: { hash?: { locale?: string; style?: 'short' | 'medium' | 'long' | 'full' } },
): string => {
  const locale = options?.hash?.locale || 'en-US';
  const style = options?.hash?.style || 'medium';
  try {
    return new Intl.DateTimeFormat(locale, { dateStyle: style, timeStyle: 'short' }).format(
      new Date(isoString),
    );
  } catch {
    return isoString;
  }
};

// ---------------------------------------------------------------------------
// 3. statusBadge — Return a styled status label with colour hint
// ---------------------------------------------------------------------------
/**
 * Usage: {{acme.helpers.status-badge "ACTIVE"}}
 *
 * Returns an HTML snippet: <span class="status-badge status-ACTIVE">Active</span>
 * Pair with CSS in the component or a shared stylesheet.
 */
const STATUS_COLOURS: Record<string, string> = {
  ACTIVE: '#4CAF50',
  CREATED: '#2196F3',
  COMPLETE: '#9E9E9E',
  CANCELLED: '#F44336',
  EXPIRED: '#FF9800',
};

export const statusBadge = (status: string): string => {
  const colour = STATUS_COLOURS[status] || '#757575';
  const label = status.charAt(0) + status.slice(1).toLowerCase();
  return `<span class="status-badge status-${status}" style="color:${colour}">${label}</span>`;
};
```

**Handlebars `options` destructuring pattern — quick reference:**

```typescript
// Positional helper (most common)
export const myHelper = (arg1: string, arg2: number, options?: { hash?: Record<string, any> }): string => {
  const fallback = options?.hash?.fallback || 'N/A';
  return arg1 ? `${arg1} (${arg2})` : fallback;
};
// manifest: {{ns.helpers.my-helper "hello" 42 fallback="none"}}

// Block helper (renders inner template conditionally)
export const ifStatus = (status: string, target: string, options: any): string => {
  return status === target ? options.fn(this) : options.inverse(this);
};
// manifest: {{#ns.helpers.if-status entity.status "ACTIVE"}}...{{else}}...{{/ns.helpers.if-status}}
```

### Template Helper Test — src/helpers/__tests__/<Name>.test.ts

```typescript
import { formatCurrency, formatDate, statusBadge } from '../<Name>';

describe('<Name> helpers', () => {
  // --- formatCurrency ---
  it('formats currency correctly', () => {
    expect(formatCurrency(99.99, 'USD')).toBe('$99.99');
  });
  it('handles zero amount', () => {
    expect(formatCurrency(0, 'AUD')).toBe('A$0.00');
  });
  it('respects locale hash param', () => {
    const result = formatCurrency(1234.5, 'EUR', { hash: { locale: 'de-DE' } });
    expect(result).toContain('1.234,50');
  });
  it('falls back on invalid currency code', () => {
    expect(formatCurrency(10, 'INVALID')).toBe('INVALID 10');
  });

  // --- formatDate ---
  it('formats ISO date string', () => {
    const result = formatDate('2026-03-01T10:30:00Z');
    expect(typeof result).toBe('string');
    expect(result.length).toBeGreaterThan(0);
  });
  it('returns raw string on invalid date', () => {
    expect(formatDate('not-a-date')).toBe('not-a-date');
  });

  // --- statusBadge ---
  it('returns styled span for known status', () => {
    const badge = statusBadge('ACTIVE');
    expect(badge).toContain('status-ACTIVE');
    expect(badge).toContain('#4CAF50');
    expect(badge).toContain('Active');
  });
  it('uses fallback colour for unknown status', () => {
    const badge = statusBadge('CUSTOM');
    expect(badge).toContain('#757575');
  });
});
```

### Registration in src/index.tsx

Add template helper registrations after component and field registrations:

```typescript
// Template helpers (register after components and fields)
import { TemplateRegistry } from 'mystique/registry/TemplateRegistry';
import { formatCurrency, formatDate, statusBadge } from './helpers/<Name>';

TemplateRegistry.register('acme.helpers.format-currency', formatCurrency);
TemplateRegistry.register('acme.helpers.format-date', formatDate);
TemplateRegistry.register('acme.helpers.status-badge', statusBadge);
```

