-
Notifications
You must be signed in to change notification settings - Fork 991
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Adds a generator for creating og:image components (#10550)
Co-authored-by: Josh GM Walker <56300765+Josh-Walker-GM@users.noreply.github.com>
- Loading branch information
1 parent
e07e6b0
commit 6302bd3
Showing
5 changed files
with
364 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
- Adds a generator for creating og:image components (#10550) by @cannikin |
20 changes: 20 additions & 0 deletions
20
packages/cli/src/commands/generate/ogImage/__tests__/__snapshots__/ogImage.test.jsx.snap
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
162
packages/cli/src/commands/generate/ogImage/__tests__/ogImage.test.jsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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() | ||
}) | ||
}) | ||
}) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
} | ||
} |
13 changes: 13 additions & 0 deletions
13
packages/cli/src/commands/generate/ogImage/templates/ogImage.og.tsx.template
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> | ||
</> | ||
) | ||
} |