Skip to content

Commit

Permalink
Adds a generator for creating og:image components (#10550)
Browse files Browse the repository at this point in the history
Co-authored-by: Josh GM Walker <56300765+Josh-Walker-GM@users.noreply.github.com>
  • Loading branch information
cannikin and Josh-Walker-GM authored May 7, 2024
1 parent e07e6b0 commit 6302bd3
Show file tree
Hide file tree
Showing 5 changed files with 364 additions and 0 deletions.
1 change: 1 addition & 0 deletions .changesets/10550.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- Adds a generator for creating og:image components (#10550) by @cannikin
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html

exports[`ogImage generator > files > returns the template to be written 1`] = `
"export const data = async () => {
return new Date()
}
export const output = ({ data }) => {
return (
<>
<h1>AboutPage og:image</h1>
<p>
Find me in <code>./web/src/pages/AboutPage/AboutPage.og.jsx</code>
</p>
<p>The time is now {data.toISOString()}</p>
</>
)
}
"
`;
162 changes: 162 additions & 0 deletions packages/cli/src/commands/generate/ogImage/__tests__/ogImage.test.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
/* eslint-disable camelcase */
globalThis.__dirname = __dirname

import { vol, fs as memfs } from 'memfs'
import { afterEach, beforeEach, describe, test, expect, vi } from 'vitest'

import { ensurePosixPath } from '@redwoodjs/project-config'

import * as ogImage from '../ogImage'

vi.mock('fs', () => ({ ...memfs, default: { ...memfs } }))
vi.mock('node:fs', () => ({ ...memfs, default: { ...memfs } }))
vi.mock('@redwoodjs/project-config', async (importOriginal) => {
const actual = await importOriginal()

return {
...actual,
getPaths: () => ({
api: {
base: '/path/to/project/api',
},
web: {
base: '/path/to/project/web',
generators: '/path/to/project/web/src/generators',
pages: '/path/to/project/web/src/pages',
},
base: '/path/to/project',
}),
}
})

let original_RWJS_CWD

describe('ogImage generator', () => {
beforeEach(() => {
original_RWJS_CWD = process.env.RWJS_CWD
process.env.RWJS_CWD = '/path/to/project'
vol.fromJSON(
{
'redwood.toml': '',
'web/src/pages/AboutPage/AboutPage.jsx': 'This is the AboutPage',
'web/src/pages/ContactUsPage/ContactUsPage.jsx':
'This is the ContactUsPage',
'web/src/pages/Products/Display/ProductPage/ProductPage.tsx':
'This is the ProductsPage',
},
'/path/to/project',
)
})

afterEach(() => {
vi.clearAllMocks()
process.env.RWJS_CWD = original_RWJS_CWD
})

describe('files', () => {
test('returns the path to the .jsx template to be written', async () => {
const files = await ogImage.files({
pagePath: 'AboutPage/AboutPage',
typescript: false,
})
const filePath = ensurePosixPath(Object.keys(files)[0])

expect(filePath).toEqual(
'/path/to/project/web/src/pages/AboutPage/AboutPage.og.jsx',
)
})

test('returns the path to the .tsx template to be written', async () => {
const files = await ogImage.files({
pagePath: 'AboutPage/AboutPage',
typescript: true,
})
const filePath = ensurePosixPath(Object.keys(files)[0])

expect(filePath).toEqual(
'/path/to/project/web/src/pages/AboutPage/AboutPage.og.tsx',
)
})

test('returns the path to the template when the page is nested in subdirectories', async () => {
const files = await ogImage.files({
pagePath: 'Products/Display/ProductPage/ProductPage',
typescript: true,
})
const filePath = ensurePosixPath(Object.keys(files)[0])

expect(filePath).toEqual(
'/path/to/project/web/src/pages/Products/Display/ProductPage/ProductPage.og.tsx',
)
})

test('returns the template to be written', async () => {
const files = await ogImage.files({
pagePath: 'AboutPage/AboutPage',
typescript: false,
})

expect(Object.values(files)[0]).toMatchSnapshot()
})
})

describe('normalizedPath', () => {
test('returns an array without a leading "pages" dir', () => {
expect(ogImage.normalizedPath('pages/AboutPage/AboutPage')).toEqual(
'AboutPage/AboutPage',
)
})

test('returns an array prepended with a missing page directory', () => {
expect(ogImage.normalizedPath('AboutPage')).toEqual('AboutPage/AboutPage')
})

test('returns an array when page is nested in subdirectories', () => {
expect(
ogImage.normalizedPath('Products/Display/ProductPage/ProductPage'),
).toEqual('Products/Display/ProductPage/ProductPage')
})

test('returns an array including a missing page directory when deeply nested', () => {
expect(ogImage.normalizedPath('Products/Display/ProductPage')).toEqual(
'Products/Display/ProductPage/ProductPage',
)
})
})

describe('validatePath', () => {
test('does nothing if path to jsx page exists', async () => {
await expect(
ogImage.validatePath('AboutPage/AboutPage', 'jsx', { fs: memfs }),
).resolves.toEqual(true)
})

test('does nothing if path to tsx page exists in nested directory structure', async () => {
await expect(
ogImage.validatePath(
'Products/Display/ProductPage/ProductPage',
'tsx',
{ fs: memfs },
),
).resolves.toEqual(true)
})

test('does nothing if path to tsx page exists in nested directory structure', async () => {
const pagePath = 'ContactUsPage/ContactUsPage'
const ext = 'tsx'
await expect(
ogImage.validatePath(pagePath, ext, {
fs: memfs,
}),
).rejects.toThrow()
})

test('throws an error if page does not exist', async () => {
const pagePath = 'HomePage/HomePage'
const ext = 'jsx'
await expect(
ogImage.validatePath(pagePath, ext, { fs: memfs }),
).rejects.toThrow()
})
})
})
168 changes: 168 additions & 0 deletions packages/cli/src/commands/generate/ogImage/ogImage.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
import fs from 'node:fs'
import path from 'node:path'

import fg from 'fast-glob'
import { Listr } from 'listr2'
import terminalLink from 'terminal-link'

import { recordTelemetryAttributes } from '@redwoodjs/cli-helpers'
import { ensurePosixPath } from '@redwoodjs/project-config'
import { errorTelemetry } from '@redwoodjs/telemetry'

import { transformTSToJS } from '../../../lib'
import { generateTemplate, getPaths, writeFilesTask } from '../../../lib'
import c from '../../../lib/colors'
import { isTypeScriptProject } from '../../../lib/project'
import { prepareForRollback } from '../../../lib/rollback'
import { customOrDefaultTemplatePath } from '../helpers'

export const files = async ({ pagePath, typescript = false }) => {
const extension = typescript ? '.tsx' : '.jsx'
const componentOutputPath = path.join(
getPaths().web.pages,
pagePath + '.og' + extension,
)
const fullTemplatePath = customOrDefaultTemplatePath({
generator: 'ogImage',
templatePath: 'ogImage.og.tsx.template',
side: 'web',
})
const content = await generateTemplate(fullTemplatePath, {
name: 'ogImage',
outputPath: ensurePosixPath(
`./${path.relative(getPaths().base, componentOutputPath)}`,
),
pageName: pagePath.split('/').pop(),
})
const template = typescript
? content
: await transformTSToJS(componentOutputPath, content)

return {
[componentOutputPath]: template,
}
}

export const normalizedPath = (pagePath) => {
const parts = pagePath.split('/')

// did it start with a leading `pages/`?
if (parts[0] === 'pages') {
parts.shift()
}

// is it JUST the name of the page, no parent directory?
if (parts.length === 1) {
return [parts[0], parts[0]].join('/')
}

// there's at least one directory, so now just be sure to double up on the page/subdir name
if (parts[parts.length - 1] === parts[parts.length - 2]) {
return parts.join('/')
} else {
const dir = parts.pop()
return [...parts, dir, dir].join('/')
}
}

export const validatePath = async (pagePath, extension, options) => {
const finalPath = `${pagePath}.${extension}`

// Optionally pass in a file system to make things easier to test!
const pages = await fg(finalPath, {
cwd: getPaths().web.pages,
fs: options?.fs || fs,
})

if (!pages.length) {
throw Error(`The page ${path.join(pagePath)}.${extension} does not exist`)
}

return true
}

export const description = 'Generate an og:image component'

export const command = 'og-image <path>'
export const aliases = ['ogImage', 'ogimage']

export const builder = (yargs) => {
yargs
.positional('path', {
description: `Path to the page to create the og:image component for (ex: \`Products/ProductPage\`)`,
type: 'string',
})
.epilogue(
`Also see the ${terminalLink(
'Redwood CLI Reference',
`https://redwoodjs.com/docs/cli-commands#generate-og-image`,
)}`,
)
.option('typescript', {
alias: 'ts',
description: 'Generate TypeScript files',
type: 'boolean',
default: isTypeScriptProject(),
})
.option('force', {
alias: 'f',
description: 'Overwrite existing files',
type: 'boolean',
default: false,
})
.option('verbose', {
description: 'Print all logs',
type: 'boolean',
default: false,
})
.option('rollback', {
description: 'Revert all generator actions if an error occurs',
type: 'boolean',
default: true,
})
}

export const handler = async (options) => {
recordTelemetryAttributes({
command: `generate og-image`,
verbose: options.verbose,
rollback: options.rollback,
force: options.force,
})

const normalizedPagePath = normalizedPath(options.path)
const extension = options.typescript ? 'tsx' : 'jsx'

try {
await validatePath(normalizedPagePath, extension)

const tasks = new Listr(
[
{
title: `Generating og:image component...`,
task: async () => {
const f = await files({
pagePath: normalizedPagePath,
typescript: options.typescript,
})
return writeFilesTask(f, { overwriteExisting: options.force })
},
},
],
{
rendererOptions: { collapseSubtasks: false },
exitOnError: true,
renderer: options.verbose && 'verbose',
},
)

if (options.rollback && !options.force) {
prepareForRollback(tasks)
}
await tasks.run()
} catch (e) {
errorTelemetry(process.argv, e.message)
console.error(c.error(e.message))
process.exit(e?.exitCode || 1)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
export const data = async () => {
return new Date()
}

export const output = ({ data }) => {
return (
<>
<h1>${pageName} og:image</h1>
<p>Find me in <code>${outputPath}</code></p>
<p>The time is now {data.toISOString()}</p>
</>
)
}

0 comments on commit 6302bd3

Please sign in to comment.