@magnolia/cli-prototype-template
v1.0.0-preview.1
Published
PrototypeTemplate simplifies creation process of components and pages
Downloads
60
Readme
PrototypeTemplate class
The @magnolia/cli-prototype-template
package simplifies creation process of components and pages for
@magnolia/cli-create-component
and @magnolia/cli-create-page
.
Overview
Every prototype should be provided within a separate package which extends the PrototypeTemplate
class to use predefined class parameters and methods.
getProgram()
Returns program with predefined options.
PrototypeTemplate
PrototypeTemplate arguments
pathToPrototype
(abstract property): This property must be explicitly defined by subclasses, pointing to the location of their prototypes, e.g.:this.pathToPrototype = path.join(__dirname, 'resources', options.prototype);
prototypesRequirements
(abstract property): This property must be explicitly defined by subclasses, e.g.:this.prototypesRequirements = {"headless": true, "availableTypes": ["js", "jsx", "tsx"], "defaultType": "tsx"}
templateData
: A set of key-value pairs used by Handlebars. The default properties included in this object are:name
: This value is derived from the<name>
parameter provided by the user.exportName
: This is also derived from the<name>
parameter, mirroring thename
property.lightModuleName
: This value is determined based on the directory of the light module, which is specified by the-lp
or--lmPath
option.dialog
: This property combines thelightModuleName
andname
values in the format${lightModuleName}:${options.context}/${name}
.magnoliaHost
: Points to http://localhost:8080
preparedComponent
: Processed prototype filesoptions
: Options passed from userargs
: Arguments modifying behaviour
PrototypeTemplate methods
async start(): Promise
starts the create component process
stop(): void
Throws error to stop the process.
async loadExternalMethods(): Promise
Checks for config.js file in prototype folder. If found:
- merges
this.args.prototypeTemplateOptions
with object fromgetPrototypeTemplateOption
function from config.js - merges
this.templateData
with object fromgetTemplateData
function from config.js - passes super().function to all exposed functions in config.js
- merges
async prototypeTemplateArgs(): Promise
Prepares arguments and:
- merges
this.args.prototypeTemplateOptions
with object passed by -ta, --templateArgs - merges
this.templateData
with object passed by -td, --templateData
- merges
async prepareComponentFromPrototype(prototypeTemplatePath: string, componentDestinationPath: string, type: string): Promise
Prepares files from prototype and passed them with handlebars to
this.preparedComponent
, each file has the following structure:// 'spa' for file from spa folder, or 'lm' for file from spa folder type: string; // unprocessed source file path, e.g.: /PATH/TO/RESORCES/SPA/_default/spa/js/{{name}}.js.hbs srcPath: string; // unprocessed destination file path, e.g.: /PATH/TO/PROJECT/COMPONENTS/{{name}}.js.hbs destPath: string; // processed destination file path, e.g.: /PATH/TO/PROJECT/COMPONENTS/newComponent.js hbDestPath: string; // unprocessed file content, e.g.: // import React from 'react'; // const {{name}} = props => <h2>{props.text}</h2>; // export default {{exportName}}; content: string; // processed file content, e.g.: // import React from 'react'; // const newComponent = props => <h2>{props.text}</h2>; // export default newComponent; hbContent: string;
async preventDuplicateComponentCreation(): Promise
Cycles throw
this.preparedComponent
and if anyhbDestPath
exists it stops the component creationasync createComponent(): Promise
Cycles throw
this.preparedComponent
, and creates everyhbDestPath
with content fromhbContent
.async handleComponentMapping(): Promise
In case of headless frameworks, calls following three functions.
async buildComponentMappingString(): Promise
Sets
this.args.componentMapping
to"${this.templateData.dialog}": ${this.templateData.exportName}
async buildImportString(): Promise
Sets
this.args.import
toimport ${exportComponentName} from '${importSource}'\n
async writeComponentMapping(): Promise
Adds
this.args.import
andthis.args.componentMapping
to the file containing componentsMapping object.
config.js file in prototype
It is possible to create config.js file in each prototype. This file allows to pass custom
this.args.prototypeTemplateOptions
object and this.templateData
by creating those two functions:
- prototypeTemplateOptions, e.g.
export function getPrototypeTemplateOption() { // Sets `importSource` to `{{name}}.${this.options.type}.hbs` to determine which path should be used in components mapping file // Overrides default value of `removeExtension` to true, to remove externsion from importString in components mapping file return { "headlessConfig": { "importSource": `{{name}}.${this.options.type}.hbs`, "removeExtension": true } } }
- getTemplateData, e.g.:
function kebabize(str) { return str.split('').map((letter, idx) => { return letter.toUpperCase() === letter ? `${idx !== 0 ? '-' : ''}${letter.toLowerCase()}` : letter; }).join(''); } export function getTemplateData() { // Add "selector" with coresponding value to be used with Handlebars return { "selector": kebabize(this.templateData.name) } }
It is also possible to override all PrototypeTemplate methods to modify the behaviour of component creation, except start, and stop method, e.g.:
- Override createComponent to add other file after a component is created:
// WARNING: requires node v18 and higher to use native fetch function import path from "path"; import * as fs from "fs"; import { pipeline } from 'stream/promises'; export async function createComponent(superCreateComponent) { // Call original createComponent from PrototypeTemplate class superCreateComponent(); // Download random image from unsplash.com and add it images folder try { const url = `https://source.unsplash.com/random/200x200`; const response = await fetch(url); if (!response.ok) { console.error(`Failed to fetch image: ${response.statusText}`); return } const folderPath = path.join(this.options.spaPath, 'images') if (!fs.existsSync(folderPath)) { fs.mkdirSync(folderPath, {recursive: true}); } const filePath = path.join(folderPath, `${this.templateData.name}.png`); const fileStream = fs.createWriteStream(filePath); await pipeline(response.body, fileStream); console.log(`Image successfully downloaded to: ${filePath}`); } catch (error) { console.error('Error downloading random image:', error); } }
Example
example-components project
Project structure
/package.json
/tsconfig.json
/index.ts
/example-prototypes.ts
/resources
|--/components
| |--/_default
| | |-- ...
| |--/complex
| | |-- ...
|--/pages
| |--/_default
| | |-- ...
| |--/complex
| | |-- ...
This example is in git:
package.json:
{ "name": "example-prototypes", "version": "1.0.0", "type": "module", "bin": { "create": "./dist/index.js" }, "main": "./dist/index.js", "types": "./dist/index.d.ts", "scripts": { "build": "tsc && cpy resources dist" }, "devDependencies": { "@types/node": "^20.11.5", "cpy-cli": "^5.0.0" }, "dependencies": { "@magnolia/cli-prototype-template": "^1.0.0" } }
tsconfig.json:
{ "compilerOptions": { "target": "es6", "module": "esnext", "outDir": "dist", "declaration": true, "moduleResolution": "node", "strict": true, "esModuleInterop": true, "resolveJsonModule": true }, "include": ["**/*"], "exclude": [ "node_modules", "dist", "resources" ] }
index.ts:
#!/usr/bin/env node import { ExamplePrototypes } from "./example-prototypes.js" import {getProgram} from "@magnolia/cli-prototype-template"; const program = getProgram() const exampleComponent = new ExamplePrototypes(program.args[0], program.opts()); exampleComponent.start();
example-prototypes.ts
import {PrototypeOptions, PrototypesRequirements, PrototypeTemplate} from "@magnolia/cli-prototype-template"; import path from "path"; import {fileURLToPath} from "url"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); export class ExamplePrototypes extends PrototypeTemplate { pathToPrototype: string; prototypesRequirements: PrototypesRequirements; constructor(name: string, options: PrototypeOptions) { super(name, options); // options.context determines 'pages' or 'components' prototypes // options.prototype is the name of the prototype ('_default' or 'complex' in our case) this.pathToPrototype = path.join(__dirname, 'resources', options.context, options.prototype); this.prototypesRequirements = {"headless": true, "availableTypes": ["js", "jsx", "tsx"], "defaultType": "tsx"} } }
resources folder, with 'components' and 'pages' prototypes:
/... /resources |--/components | |--/_default | | |-- ... | |--/complex | | |-- ... |--/pages | |--/_default | | |-- ... | |--/complex | | |-- ...
components prototypes
_default component prototype
/...
/resources
|--/components
| |--/_default
| | |--/spa
| | | |--/js
| | | | |--/{{name}}.js.hbs
| | | |--/jsx
| | | | |--/{{name}}.jsx.hbs
| | | |--/tsx
| | | | |--/{{name}}.tsx.hbs
_default/spa/js/{{name}}.js.hbs and _default/spa/jsx/{{name}}.jsx.hbs files:
import React from 'react'; const {{name}} = props => <h2>{props.text}</h2>; export default {{exportName}};
_default/spa/tsx/{{name}}.tsx.hbs file:
import React from 'react'; interface I{{name}}Props { text: string; } const {{name}} = (props: I{{name}}Props) => <h2>{props.text}</h2>; export default {{exportName}};
complex component prototype with config.js
/...
/resources
|--/components
| |--/complex
| | |--/light-module
| | | |--/dialogs
| | | | |--/components
| | | | | |--/{{name}}.yaml.hbs
| | | |--/templates
| | | | |--/components
| | | | | |--/{{name}}.yaml.hbs
| | |--/spa
| | | |--/js
| | | | |--/{{name}}
| | | | | |--/{{name}}.js.hbs
| | | | | |--/{{name}}.stories.js.hbs
| | | | | |--/{{name}}.model.js.hbs
| | | |--/jsx
| | | | |--/{{name}}
| | | | | |--/{{name}}.jsx.hbs
| | | | | |--/{{name}}.stories.jsx.hbs
| | | | | |--/{{name}}.model.js.hbs
| | | |--/tsx
| | | | |--/{{name}}
| | | | | |--/{{name}}.tsx.hbs
| | | | | |--/{{name}}.stories.tsx.hbs
| | | | | |--/{{name}}.model.ts.hbs
| | |--/config.js
complex/light-module/dialogs/components/{{name}}.yaml.hbs file:
label: {{name}} form: properties: title: label: title $type: textField i18n: true description: label: description $type: textField i18n: true image: label: image $type: damLinkField
complex/light-module/templates/components/{{name}}.yaml file:
title: {{name}} dialog: {{dialog}}
complex/spa/js/{{name}}/{{name}}.stories.js and complex/spa/jsx/{{name}}/{{name}}.stories.jsx files:
import React from 'react'; import {{name}} from './{{name}}'; import { {{name}}Model } from './{{name}}.model'; export default { title: '{{name}}', component: {{name}}, }; const Template = (args) => <{{name}} {...args} />; export const Default = Template.bind({}); Default.args = new {{name}}Model('Title text', 'Description text', {"@link": "Add link to image here, e.g.: \"/magnoliaAuthor/dam/jcr:7279fb99-094f-452b-ac4c-3b4defb56203\""});
complex/spa/js/{{name}}/{{name}}.js and complex/spa/jsx/{{name}}/{{name}}.jsx files:
import React from 'react'; import PropTypes from 'prop-types'; import { {{name}}Model } from './{{name}}.model'; import randomImage from '../images/{{name}}.png'; const {{name}} = ( props ) => { return ( <div> <img src={ '{{magnoliaHost}}' + props.image['@link']} alt="image" /> <h2>{props.name}</h2> <p>{props.description}</p> <img src={randomImage} alt="randomImage" /> </div> ); }; {{name}}.propTypes = PropTypes.instanceOf({{name}}Model).isRequired; export default {{name}};
complex/spa/js/{{name}}/{{name}}.model.js and complex/spa/jsx/{{name}}/{{name}}.model.js files:
export class {{name}}Model { constructor(name, description, image) { this.name = name; this.description = description; this.image = image; } }
complex/spa/tsx/{{name}}/{{name}}.stories.tsx file:
import React from 'react'; import {{name}}, { {{name}}Props } from './{{name}}'; import { {{name}}Model } from './{{name}}.model'; import { Story, Meta } from '@storybook/react'; export default { title: '{{name}}', component: {{name}}, } as Meta; const Template: Story<{{name}}Props> = (args) => <{{name}} {...args} />; export const Default = Template.bind({}); Default.args = new {{name}}Model('Title text', 'Description text', {"@link": "Add link to image here, e.g.: \"/magnoliaAuthor/dam/jcr:7279fb99-094f-452b-ac4c-3b4defb56203\""});
complex/spa/tsx/{{name}}/{{name}}.tsx file:
import React from 'react'; import { {{name}}Model } from './{{name}}.model'; import randomImage from '../images/{{name}}.png'; const {{name}}: React.FC<{{name}}Model> = ( props ) => { return ( <div> <img src={ '{{magnoliaHost}}' + props.image['@link']} alt="image" /> <h2>{props.name}</h2> <p>{props.description}</p> <img src={randomImage} alt="randomImage" /> </div> ); }; export default {{name}};
complex/spa/tsx/{{name}}/{{name}}.model.ts file:
export class {{name}}Model { name: string; description: string; image: any; constructor(name: string, description: string, image: any) { this.name = name; this.description = description; this.image = image; } }
complex/config.js file:
// WARNING: requires node v18 and higher to use native fetch function import path from "path"; import * as fs from "fs"; import { pipeline } from 'stream/promises'; export function getPrototypeTemplateOption() { return { headlessConfig: { importSource: `{{name}}.${this.options.type}.hbs` } } } export async function createComponent(superCreateComponent) { // Call original createComponent from PrototypeTemplate class superCreateComponent(); // Download random image from https://source.unsplash.com and add it to images folder try { const url = `https://source.unsplash.com/random/200x200`; const response = await fetch(url); if (!response.ok) { console.error(`Failed to fetch image: ${response.statusText}`); return } const folderPath = path.join(this.options.spaPath, 'images') if (!fs.existsSync(folderPath)) { fs.mkdirSync(folderPath, {recursive: true}); } const filePath = path.join(folderPath, `${this.templateData.name}.png`); const fileStream = fs.createWriteStream(filePath); await pipeline(response.body, fileStream); console.log(`Image successfully downloaded to: ${filePath}`); } catch (error) { console.error('Error downloading random image:', error); } }
NOTE: The "importSource" parameter in the getPrototypeTemplateOption function is essential for specifying which file should be used as the import source in the components mapping file. This specification is crucial because the complex prototype includes multiple files within the spa folder.
pages prototypes
_default pages prototype
/...
/resources
|--/page
| |--/_default
| | |--/light-module
| | | |--/dialogs
| | | | |--/pages
| | | | | |--/{{name}}.yaml.hbs
| | | |--/templates
| | | | |--/pages
| | | | | |--/{{name}}.yaml.hbs
| | |--/spa
| | | |--/js
| | | | |--/{{name}}.js.hbs
| | | |--/jsx
| | | | |--/{{name}}.jsx.hbs
| | | |--/tsx
| | | | |--/{{name}}.tsx.hbs
_default/light-module/dialogs/pages/{{name}}.yaml.hbs file:
label: Page Properties form: properties: title: label: Title $type: textField i18n: true
_default/light-module/templates/pages/{{name}}.yaml.hbs file:
renderType: spa class: info.magnolia.rendering.spa.renderer.SpaRenderableDefinition title: {{name}} dialog: {{dialog}} baseUrl: http://localhost:3000 routeTemplate: '/{language}\{{@path}}' # templateScript: /{{lightModuleName}}/webresources/build/index.html areas: main: title: Main Area extras: title: Extras Area
_default/spa/js/{{name}}.js.hbs and _default/spa/jsx/{{name}}.jsx.hbs files:
import React from 'react'; import { EditableArea } from '@magnolia/react-editor'; const {{name}} = props => { const { main, extras, title } = props; return ( <div className="{{name}}"> <div>[Basic Page]</div> <h1>{title || props.metadata['@name']}</h1> <main> <div>[Main Area]</div> {main && <EditableArea className="Area" content={main} />} </main> <div> <div>[Secondary Area]</div> {extras && <EditableArea className="Area" content={extras} />} </div> </div> ) }; export default {{name}};
_default/spa/tsx/{{name}}.tsx.hbs file:
import React from 'react'; // @ts-ignore import { EditableArea } from '@magnolia/react-editor'; interface I{{name}} { metadata?: any; main?: any; extras?: any; title?: string; } const {{name}} = (props: I{{name}}) => { const { main, extras, title } = props; return ( <div className="{{name}}"> <div>[Basic Page]</div> <h1>{title || props.metadata['@name']}</h1> <main> <div>[Main Area]</div> {main && <EditableArea className="Area" content={main} />} </main> <div> <div>[Secondary Area]</div> {extras && <EditableArea className="Area" content={extras} />} </div> </div> ) }; export default {{name}};
complex pages prototype with config.js
/...
/resources
|--/components
| |--/complex
| | |--/light-module
| | | |--/dialogs
| | | | |--/pages
| | | | | |--/{{name}}.yaml.hbs
| | | |--/templates
| | | | |--/pages
| | | | | |--/{{name}}.yaml.hbs
| | |--/spa
| | | |--/js
| | | | |--/{{name}}
| | | | | |--/{{name}}.js.hbs
| | | | | |--/{{name}}.model.js.hbs
| | | |--/jsx
| | | | |--/{{name}}
| | | | | |--/{{name}}.jsx.hbs
| | | | | |--/{{name}}.model.js.hbs
| | | |--/tsx
| | | | |--/{{name}}
| | | | | |--/{{name}}.tsx.hbs
| | | | | |--/{{name}}.model.tsx.hbs
| | |--/config.js
complex/light-module/dialogs/pages/{{name}}.yaml.hbs file:
label: Page Properties form: properties: title: $type: textField i18n: true navigationTitle: $type: textField i18n: true windowTitle: $type: textField i18n: true abstract: $type: textField rows: 5 i18n: true keywords: $type: textField rows: 3 i18n: true description: $type: textField rows: 5 i18n: true layout: $type: tabbedLayout tabs: - name: tabMain fields: - name: title - name: navigationTitle - name: windowTitle - name: abstract - name: tabMeta fields: - name: keywords - name: description
complex/light-module/templates/pages/{{name}}.yaml.hbs file:
renderType: spa class: info.magnolia.rendering.spa.renderer.SpaRenderableDefinition title: {{name}} dialog: {{dialog}} baseUrl: http://localhost:3000 routeTemplate: '/{language}\{{@path}}' # templateScript: /{{lightModuleName}}/webresources/build/index.html areas: main: title: Main Area extras: title: Extras Area
complex/spa/js/{{name}}/{{name}}.js.hbs and complex/spa/jsx/{{name}}/{{name}}.jsx.hbs files:
import React from 'react'; import PropTypes from 'prop-types'; import { EditableArea } from '@magnolia/react-editor'; import { Helmet } from 'react-helmet'; import { {{name}}Model } from './{{name}}.model' const {{name}} = props => { const { main, extras, title, navigationTitle, description, keywords, abstract } = props; return ( <div className="{{name}}"> <Helmet> <title>{title}</title> <meta name="description" content={description} /> <meta name="keywords" content={keywords} /> <meta name="abstract" content={abstract} /> </Helmet> <h1>{navigationTitle}</h1> <p>{description}</p> <div>[Basic Page]</div> <h1>{title || props.metadata['@name']}</h1> <main> <div>[Main Area]</div> {main && <EditableArea className="Area" content={main} />} </main> <div> <div>[Secondary Area]</div> {extras && <EditableArea className="Area" content={extras} />} </div> </div> ) }; {{name}}.propTypes = PropTypes.instanceOf({{name}}Model).isRequired; export default {{name}};
complex/spa/js/{{name}}/{{name}}.model.js.hbs and complex/spa/jsx/{{name}}/{{name}}.model.js.hbs files:
export class {{name}}Model { constructor(metadata, main, extras, title, navigationTitle, windowTitle, abstract, keywords, description) { this.metadata = metadata; this.main = main; this.extras = extras; this.title = title; this.navigationTitle = navigationTitle; this.windowTitle = windowTitle; this.abstract = abstract; this.keywords = keywords; this.description = description; } }
complex/spa/js/{{name}}/{{name}}.js.hbs and complex/spa/jsx/{{name}}/{{name}}.jsx.hbs files:
import React from 'react'; import PropTypes from 'prop-types'; //@ts-ignore import { EditableArea } from '@magnolia/react-editor'; import { Helmet } from 'react-helmet'; import { {{name}}Model } from './{{name}}.model' const {{name}} = props => { const { main, extras, title, navigationTitle, description, keywords, abstract } = props; return ( <div className="{{name}}"> <Helmet> <title>{title}</title> <meta name="description" content={description} /> <meta name="keywords" content={keywords} /> <meta name="abstract" content={abstract} /> </Helmet> <h1>{navigationTitle}</h1> <p>{description}</p> <div>[Basic Page]</div> <h1>{title || props.metadata['@name']}</h1> <main> <div>[Main Area]</div> {main && <EditableArea className="Area" content={main} />} </main> <div> <div>[Secondary Area]</div> {extras && <EditableArea className="Area" content={extras} />} </div> </div> ) }; {{name}}.propTypes = PropTypes.instanceOf({{name}}Model).isRequired; export default {{name}};
complex/spa/tsx/{{name}}/{{name}}.model.ts.hbs file:
export class testPageModel { constructor(metadata: any, main: any, extras: any, title: string, navigationTitle: string, windowTitle: string, abstract: string, keywords: string, description: string) { this.metadata = metadata; this.main = main; this.extras = extras; this.title = title; this.navigationTitle = navigationTitle; this.windowTitle = windowTitle; this.abstract = abstract; this.keywords = keywords; this.description = description; } }
complex/spa/config.js.hbs file:
export function getPrototypeTemplateOption() {
return {
headlessConfig: {
importSource: `{{name}}.${this.options.type}.hbs`
}
}
}