Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

#9620: Update studio to support variable components (Mailer) #9639

Merged
19 changes: 19 additions & 0 deletions packages/studio/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,25 @@ Some ideas to improve the Studio are:
* Prisma version
* RedwoodJS Version

## Troubleshooting
If you have problems relating to the `@swc` packages then please try adding the following configuration to your `.yarnrc.yml`

```yml
supportedArchitectures:
os:
- darwin
- linux
- win32
cpu:
- arm64
- arm
- x64
- ia32
libc:
- glibc
- musl
```

## Contributing

We welcome your [feedback](https://community.redwoodjs.com/t/redwood-studio-experimental/4771) and also your contributions to improve Studio.
Expand Down
235 changes: 203 additions & 32 deletions packages/studio/api/mail/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import path from 'node:path'

import * as swc from '@swc/core'
import chokidar from 'chokidar'
import fs from 'fs-extra'
import { simpleParser as simpleMailParser } from 'mailparser'
Expand All @@ -10,8 +11,6 @@ import { getPaths } from '@redwoodjs/project-config'
import { getDatabase } from '../database'
import { getStudioConfig } from '../lib/config'

const swc = require('@swc/core')

let smtpServer: SMTPServer

async function insertMailIntoDatabase(mail: any, envelope: any) {
Expand Down Expand Up @@ -221,48 +220,220 @@ export async function updateMailTemplates() {
)
}

function generatePropsTemplate(param: swc.Param | swc.Pattern | null) {
// No param means no props template
if (!param) {
return null
}

// Get the pattern
const pattern = param.type === 'Parameter' ? param.pat : param
if (!pattern) {
return null
}

// Attempt to generate a props template from the pattern
let propsTemplate = 'Provide your props here as JSON'
try {
switch (pattern.type) {
case 'Identifier':
propsTemplate = `{${pattern.value}: ?}`
break
case 'AssignmentPattern':
if (pattern.left.type === 'ObjectPattern') {
propsTemplate = `{${pattern.left.properties
.map((p: any) => {
return `\n "${p.key.value}": ?`
})
.join(',')}\n}`
}
break
case 'ObjectPattern':
propsTemplate = `{${pattern.properties
.map((p: any) => {
return `\n "${p.key.value}": ?`
})
.join(',')}\n}`
break
}
} catch (_error) {
// ignore for now, we'll fallback to the generic props template
}

// Fallback to a generic props template if we can't figure out anything more helpful
return propsTemplate
}

function extractNameAndPropsTemplate(
component: swc.ModuleItem,
functionsAndVariables: swc.ModuleItem[]
): {
name: string
propsTemplate: string | null
} {
switch (component.type) {
case 'ExportDeclaration':
// Arrow functions
if (component.declaration.type === 'VariableDeclaration') {
// We only support the identifier type for now
const identifier = component.declaration.declarations[0].id
if (identifier.type !== 'Identifier') {
throw new Error('Unexpected identifier type: ' + identifier.type)
}
// We only support arrow and normal functions for now
const expression = component.declaration.declarations[0].init
if (!expression) {
throw new Error('Unexpected undefined expression')
}
if (
expression.type !== 'ArrowFunctionExpression' &&
expression.type !== 'FunctionExpression'
) {
throw new Error('Unexpected expression type: ' + expression.type)
}
return {
name: identifier.value,
propsTemplate: generatePropsTemplate(expression.params[0] ?? null),
}
}

// Normal functions
if (component.declaration.type === 'FunctionDeclaration') {
return {
name: component.declaration.identifier.value,
propsTemplate: generatePropsTemplate(
component.declaration.params[0] ?? null
),
}
}

// Throw for anything else
throw new Error(
'Unexpected declaration type: ' + component.declaration.type
)

case 'ExportDefaultExpression':
// Arrow functions
if (component.expression.type === 'ArrowFunctionExpression') {
return {
name: 'default',
propsTemplate: generatePropsTemplate(
component.expression.params[0] ?? null
),
}
}

// Variables defined elsewhere and then exported as default
if (component.expression.type === 'Identifier') {
const expression = component.expression
const variable = functionsAndVariables.find((v) => {
return (
(v.type === 'FunctionDeclaration' &&
v.identifier.value === expression.value) || // function
(v.type === 'VariableDeclaration' &&
v.declarations[0].type === 'VariableDeclarator' &&
v.declarations[0].id.type === 'Identifier' &&
v.declarations[0].id.value === expression.value) // variable
)
})
if (variable) {
if (variable.type === 'FunctionDeclaration') {
return {
name: variable.identifier.value + ' (default)',
propsTemplate: generatePropsTemplate(variable.params[0] ?? null),
}
}
if (variable.type === 'VariableDeclaration') {
if (variable.declarations[0].id.type !== 'Identifier') {
throw new Error(
'Unexpected identifier type: ' +
variable.declarations[0].id.type
)
}
if (
variable.declarations[0].init?.type !== 'FunctionExpression' &&
variable.declarations[0].init?.type !== 'ArrowFunctionExpression'
) {
throw new Error(
'Unexpected init type: ' + variable.declarations[0].init?.type
)
}
return {
name: variable.declarations[0].id.value + ' (default)',
propsTemplate: generatePropsTemplate(
variable.declarations[0].init?.params[0] ?? null
),
}
}
}
}

// Throw for anything else
throw new Error(
'Unexpected expression type: ' + component.expression.type
)

case 'ExportDefaultDeclaration':
// Normal functions
if (component.decl.type === 'FunctionExpression') {
let name = 'default'
if (component.decl.identifier) {
name = component.decl.identifier.value
}
return {
name,
propsTemplate: generatePropsTemplate(
component.decl.params[0] ?? null
),
}
}

// Throw for anything else
throw new Error('Unexpected declaration type: ' + component.decl.type)

default:
throw new Error('Unexpected component type: ' + component.type)
}
}

function getMailTemplateComponents(templateFilePath: string) {
const ast = swc.parseFileSync(templateFilePath, {
syntax: templateFilePath.endsWith('.js') ? 'ecmascript' : 'typescript',
tsx: templateFilePath.endsWith('.tsx') || templateFilePath.endsWith('.jsx'),
})

const components = []
const components: { name: string; propsTemplate: string | null }[] = []
const functionsAndVariables = ast.body.filter((node: any) => {
return (
node.type === 'VariableDeclaration' || node.type === 'FunctionDeclaration'
)
})

// `export function X(){};`
const exportedComponents = ast.body.filter((node: any) => {
return node.type === 'ExportDeclaration'
return [
'ExportDeclaration',
'ExportDefaultDeclaration',
'ExportDefaultExpression',
].includes(node.type)
})
for (let i = 0; i < exportedComponents.length; i++) {
let propsTemplate = null
const hasParams = exportedComponents[i].declaration.params.length > 0
if (hasParams) {
propsTemplate = 'Provide your props here as JSON'
try {
const param = exportedComponents[i].declaration.params[0]
switch (param.pat.type) {
case 'ObjectPattern':
propsTemplate = `{${param.pat.properties
.map((p: any) => {
return `\n "${p.key.value}": ?`
})
.join(',')}\n}`
break
}
} catch (_error) {
// Ignore for now
}
try {
const { propsTemplate, name } = extractNameAndPropsTemplate(
exportedComponents[i],
functionsAndVariables
)
components.push({
name,
propsTemplate,
})
} catch (error) {
console.error(
`Error extracting template component name and props template from ${templateFilePath}:`
)
console.error(error)
}
components.push({
name: exportedComponents[i].declaration?.identifier?.value ?? 'Unknown',
propsTemplate,
})
}

// TODO: Support `const X = () => {}; export default X;`
// TODO: Support `export default function X () => {}`
// TODO: Support `export default () => {}`

return components
}

Expand All @@ -278,7 +449,7 @@ export async function updateMailRenderers() {
const suffix = `studio_${Date.now()}`
const importPath = mailerFilePath.replace('.js', `.${suffix}.js`)
fs.copyFileSync(mailerFilePath, importPath)
const mailer = (await import(importPath)).mailer
const mailer = (await import(`file://${importPath}`)).mailer
fs.removeSync(importPath)
const renderers = Object.keys(mailer.renderers)
const defaultRenderer = mailer.config.rendering.default
Expand Down
5 changes: 3 additions & 2 deletions packages/studio/api/migrations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,9 +59,10 @@ async function migrate001(db: Database<sqlite3.Database, sqlite3.Statement>) {
CREATE TABLE IF NOT EXISTS mail_template_component (
id INTEGER PRIMARY KEY AUTOINCREMENT,
mail_template_id INTEGER NOT NULL,
name TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
props_template TEXT,
updated_at INTEGER DEFAULT (strftime('%s', 'now'))
updated_at INTEGER DEFAULT (strftime('%s', 'now')),
UNIQUE(mail_template_id, name)
);
CREATE TABLE IF NOT EXISTS mail_renderer (
id INTEGER PRIMARY KEY AUTOINCREMENT,
Expand Down
11 changes: 8 additions & 3 deletions packages/studio/api/services/mail.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,9 +105,14 @@ export async function getRenderedMail(
`.studio_${Date.now()}.js`
)
fs.copyFileSync(templateComponentDistPath, templateImportPath)
const templateComponent = await import(templateImportPath)
const templateComponent = (await import(`file://${templateImportPath}`))
.default
fs.removeSync(templateImportPath)
const Component = templateComponent[component.name]

const Component =
component.name.indexOf('default') !== -1
? templateComponent.default
: templateComponent[component.name]

// Import the mailer
const mailerFilePath = path.join(getPaths().api.dist, 'lib', 'mailer.js')
Expand All @@ -116,7 +121,7 @@ export async function getRenderedMail(
`.studio_${Date.now()}.js`
)
fs.copyFileSync(mailerFilePath, mailerImportPath)
const mailer = (await import(mailerImportPath)).mailer
const mailer = (await import(`file://${mailerImportPath}`)).mailer
fs.removeSync(mailerImportPath)

// Render the component
Expand Down
20 changes: 16 additions & 4 deletions packages/studio/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@
"graphql-yoga": "4.0.4",
"jsonwebtoken": "9.0.2",
"lodash": "4.17.21",
"mailparser": "^3.6.5",
"mailparser": "3.6.5",
"pretty-bytes": "5.6.0",
"qs": "6.11.2",
"smtp-server": "3.13.0",
Expand All @@ -68,12 +68,12 @@
"@types/aws-lambda": "8.10.126",
"@types/jsonwebtoken": "9.0.5",
"@types/lodash": "4.14.201",
"@types/mailparser": "^3",
"@types/mailparser": "3",
"@types/qs": "6.9.10",
"@types/react": "18.2.37",
"@types/react-dom": "18.2.15",
"@types/react-grid-layout": "1",
"@types/smtp-server": "^3",
"@types/smtp-server": "3",
"@types/split2": "4.2.3",
"@types/uuid": "9.0.7",
"@types/yargs": "17.0.31",
Expand All @@ -98,5 +98,17 @@
"use-url-search-params": "2.5.1",
"vite": "4.5.1"
},
"gitHead": "3905ed045508b861b495f8d5630d76c7a157d8f1"
"gitHead": "3905ed045508b861b495f8d5630d76c7a157d8f1",
"optionalDependencies": {
"@swc/core-darwin-arm64": "1.3.60",
"@swc/core-darwin-x64": "1.3.60",
"@swc/core-linux-arm-gnueabihf": "1.3.60",
"@swc/core-linux-arm64-gnu": "1.3.60",
"@swc/core-linux-arm64-musl": "1.3.60",
"@swc/core-linux-x64-gnu": "1.3.60",
"@swc/core-linux-x64-musl": "1.3.60",
"@swc/core-win32-arm64-msvc": "1.3.60",
"@swc/core-win32-ia32-msvc": "1.3.60",
"@swc/core-win32-x64-msvc": "1.3.60"
}
}
Loading
Loading