From 69a61ca51cd54385e3f7a75d432a0f7750e527b2 Mon Sep 17 00:00:00 2001 From: Owen Buckley Date: Sat, 12 Aug 2023 11:49:42 -0400 Subject: [PATCH] Feature/issue 1008 vercel adapter plugin (#1139) * initial implementation of a vercel adapter plugin * add test case for static build output * handle request and response properties * finishing touches to README.md * add to custom plugins docs page * link to edge runtime support issue in caveats sections * handle windows pathname interop --- .gitignore | 1 + packages/plugin-adapter-netlify/README.md | 2 +- packages/plugin-adapter-vercel/README.md | 64 +++++ packages/plugin-adapter-vercel/package.json | 32 +++ packages/plugin-adapter-vercel/src/index.js | 150 ++++++++++ .../cases/build.default/build.default.spec.js | 265 ++++++++++++++++++ .../cases/build.default/greenwood.config.js | 7 + .../cases/build.default/src/api/fragment.js | 27 ++ .../cases/build.default/src/api/greeting.js | 14 + .../build.default/src/components/card.js | 27 ++ .../cases/build.default/src/pages/artists.js | 25 ++ .../cases/build.default/src/pages/users.js | 27 ++ .../build.default/src/services/artists.js | 11 + .../build.default/src/services/message.js | 7 + www/pages/plugins/custom-plugins.md | 3 +- 15 files changed, 660 insertions(+), 2 deletions(-) create mode 100644 packages/plugin-adapter-vercel/README.md create mode 100644 packages/plugin-adapter-vercel/package.json create mode 100644 packages/plugin-adapter-vercel/src/index.js create mode 100644 packages/plugin-adapter-vercel/test/cases/build.default/build.default.spec.js create mode 100644 packages/plugin-adapter-vercel/test/cases/build.default/greenwood.config.js create mode 100644 packages/plugin-adapter-vercel/test/cases/build.default/src/api/fragment.js create mode 100644 packages/plugin-adapter-vercel/test/cases/build.default/src/api/greeting.js create mode 100644 packages/plugin-adapter-vercel/test/cases/build.default/src/components/card.js create mode 100644 packages/plugin-adapter-vercel/test/cases/build.default/src/pages/artists.js create mode 100644 packages/plugin-adapter-vercel/test/cases/build.default/src/pages/users.js create mode 100644 packages/plugin-adapter-vercel/test/cases/build.default/src/services/artists.js create mode 100644 packages/plugin-adapter-vercel/test/cases/build.default/src/services/message.js diff --git a/.gitignore b/.gitignore index 585715782..a56ab6ff6 100644 --- a/.gitignore +++ b/.gitignore @@ -9,5 +9,6 @@ packages/**/test/**/yarn.lock packages/**/test/**/package-lock.json packages/**/test/**/netlify packages/**/test/**/.netlify +packages/**/test/**/.vercel public/ adapter-outlet/ \ No newline at end of file diff --git a/packages/plugin-adapter-netlify/README.md b/packages/plugin-adapter-netlify/README.md index a98f2ce6d..01c2b62c2 100644 --- a/packages/plugin-adapter-netlify/README.md +++ b/packages/plugin-adapter-netlify/README.md @@ -79,7 +79,7 @@ Then when you run it, you will be able to run and test a production build of you > _Please see caveats section for more information on this feature. 👇_ ## Caveats -1. [Edge runtime](https://docs.netlify.com/edge-functions/overview/) is not supported yet. +1. [Edge runtime](https://docs.netlify.com/edge-functions/overview/) is not supported ([yet](https://github.com/ProjectEvergreen/greenwood/issues/1141)). 1. Netlify CLI / Local Dev - [`context` object](https://docs.netlify.com/functions/create/?fn-language=js#code-your-function-2) not supported when running `greenwood develop` command - [`import.meta.url` is not supported in the Netlify CLI](https://github.com/netlify/cli/issues/4601) and in particular causes [WCC to break](https://github.com/ProjectEvergreen/greenwood-demo-adapter-netlify#-importmetaurl). \ No newline at end of file diff --git a/packages/plugin-adapter-vercel/README.md b/packages/plugin-adapter-vercel/README.md new file mode 100644 index 000000000..d8f3458a2 --- /dev/null +++ b/packages/plugin-adapter-vercel/README.md @@ -0,0 +1,64 @@ +# @greenwood/plugin-adapter-vercel + +## Overview +Enables usage of Vercel Serverless runtimes for API routes and SSR pages. + +> This package assumes you already have `@greenwood/cli` installed. + +## Features + +In addition to publishing a project's static assets to the Vercel's CDN, this plugin adapts Greenwood [API routes](https://www.greenwoodjs.io/docs/api-routes/) and [SSR pages](https://www.greenwoodjs.io/docs/server-rendering/) into Vercel [Serverless functions](https://vercel.com/docs/concepts/functions/serverless-functions) using their [Build Output API](https://vercel.com/docs/build-output-api/v3). + +> _**Note:** You can see a working example of this plugin [here](https://github.com/ProjectEvergreen/greenwood-demo-adapter-vercel)_. + + +## Installation +You can use your favorite JavaScript package manager to install this package. + +_examples:_ +```bash +# npm +npm install @greenwood/plugin-adapter-vercel --save-dev + +# yarn +yarn add @greenwood/plugin-adapter-vercel --dev +``` + +You will then want to create a _vercel.json_ file, customized to match your project. Assuming you have an npm script called `build` +```json +{ + "scripts": { + "build": "greenwood build" + } +} +``` + +This would be the minimum _vercel.json_ configuration you would need +```json +{ + "buildCommand": "npm run build" +} +``` + +## Usage +Add this plugin to your _greenwood.config.js_. + +```javascript +import { greenwoodPluginAdapterVercel } from '@greenwood/plugin-adapter-vercel'; + +export default { + ... + + plugins: [ + greenwoodPluginAdapterVercel() + ] +} +``` + + +## Caveats +1. [Edge runtime](https://vercel.com/docs/concepts/functions/edge-functions) is not supported ([yet](https://github.com/ProjectEvergreen/greenwood/issues/1141)). +1. The Vercel CLI (`vercel dev`) is not compatible with Build Output v3. + ```sh + Error: Detected Build Output v3 from "npm run build", but it is not supported for `vercel dev`. Please set the Development Command in your Project Settings. + ``` \ No newline at end of file diff --git a/packages/plugin-adapter-vercel/package.json b/packages/plugin-adapter-vercel/package.json new file mode 100644 index 000000000..482e1dffe --- /dev/null +++ b/packages/plugin-adapter-vercel/package.json @@ -0,0 +1,32 @@ +{ + "name": "@greenwood/plugin-adapter-vercel", + "version": "0.29.0-alpha.1", + "description": "A Greenwood plugin for supporting Vercel serverless and edge runtimes.", + "repository": "https://github.com/ProjectEvergreen/greenwood", + "homepage": "https://github.com/ProjectEvergreen/greenwood/tree/master/packages/plugin-adapter-vercel", + "author": "Owen Buckley ", + "license": "MIT", + "keywords": [ + "Greenwood", + "Static Site Generator", + "SSR", + "Full Stack Web Development", + "Vercel", + "Serverless", + "Edge" + ], + "main": "src/index.js", + "type": "module", + "files": [ + "src/" + ], + "publishConfig": { + "access": "public" + }, + "peerDependencies": { + "@greenwood/cli": "^0.28.0" + }, + "devDependencies": { + "@greenwood/cli": "^0.29.0-alpha.1" + } +} diff --git a/packages/plugin-adapter-vercel/src/index.js b/packages/plugin-adapter-vercel/src/index.js new file mode 100644 index 000000000..1553ff347 --- /dev/null +++ b/packages/plugin-adapter-vercel/src/index.js @@ -0,0 +1,150 @@ +import fs from 'fs/promises'; +import path from 'path'; +import { checkResourceExists } from '@greenwood/cli/src/lib/resource-utils.js'; + +function generateOutputFormat(id, type) { + const path = type === 'page' + ? `__${id}` + : id; + + return ` + import { handler as ${id} } from './${path}.js'; + + export default async function handler (request, response) { + const { url, headers, method } = request; + const req = new Request(new URL(url, \`http://\${headers.host}\`), { + headers: new Headers(headers), + method + }); + const res = await ${id}(req); + + res.headers.forEach((value, key) => { + response.setHeader(key, value); + }); + response.status(res.status); + response.send(await res.text()); + } + `; +} + +async function setupFunctionBuildFolder(id, outputType, outputRoot) { + const outputFormat = generateOutputFormat(id, outputType); + + await fs.mkdir(outputRoot, { recursive: true }); + await fs.writeFile(new URL('./index.js', outputRoot), outputFormat); + await fs.writeFile(new URL('./package.json', outputRoot), JSON.stringify({ + type: 'module' + })); + await fs.writeFile(new URL('./.vc-config.json', outputRoot), JSON.stringify({ + runtime: 'nodejs18.x', + handler: 'index.js', + launcherType: 'Nodejs', + shouldAddHelpers: true + })); +} + +async function vercelAdapter(compilation) { + const { outputDir, projectDirectory } = compilation.context; + const adapterOutputUrl = new URL('./.vercel/output/functions/', projectDirectory); + const ssrPages = compilation.graph.filter(page => page.isSSR); + const apiRoutes = compilation.manifest.apis; + + if (!await checkResourceExists(adapterOutputUrl)) { + await fs.mkdir(adapterOutputUrl, { recursive: true }); + } + + await fs.writeFile(new URL('./.vercel/output/config.json', projectDirectory), JSON.stringify({ + 'version': 3 + })); + + const files = await fs.readdir(outputDir); + const isExecuteRouteModule = files.find(file => file.startsWith('execute-route-module')); + + for (const page of ssrPages) { + const outputType = 'page'; + const { id } = page; + const outputRoot = new URL(`./${id}.func/`, adapterOutputUrl); + + await setupFunctionBuildFolder(id, outputType, outputRoot); + + await fs.cp( + new URL(`./_${id}.js`, outputDir), + new URL(`./_${id}.js`, outputRoot), + { recursive: true } + ); + + await fs.cp( + new URL(`./__${id}.js`, outputDir), + new URL(`./__${id}.js`, outputRoot), + { recursive: true } + ); + + // TODO quick hack to make serverless pages are fully self-contained + // for example, execute-route-module.js will only get code split if there are more than one SSR pages + // https://github.com/ProjectEvergreen/greenwood/issues/1118 + if (isExecuteRouteModule) { + await fs.cp( + new URL(`./${isExecuteRouteModule}`, outputDir), + new URL(`./${isExecuteRouteModule}`, outputRoot) + ); + } + + // TODO how to track SSR resources that get dumped out in the public directory? + // https://github.com/ProjectEvergreen/greenwood/issues/1118 + const ssrPageAssets = (await fs.readdir(outputDir)) + .filter(file => !path.basename(file).startsWith('_') + && !path.basename(file).startsWith('execute') + && path.basename(file).endsWith('.js') + ); + + for (const asset of ssrPageAssets) { + await fs.cp( + new URL(`./${asset}`, outputDir), + new URL(`./${asset}`, outputRoot), + { recursive: true } + ); + } + } + + for (const [key] of apiRoutes) { + const outputType = 'api'; + const id = key.replace('/api/', ''); + const outputRoot = new URL(`./api/${id}.func/`, adapterOutputUrl); + + await setupFunctionBuildFolder(id, outputType, outputRoot); + + // TODO ideally all functions would be self contained + // https://github.com/ProjectEvergreen/greenwood/issues/1118 + await fs.cp( + new URL(`./api/${id}.js`, outputDir), + new URL(`./${id}.js`, outputRoot), + { recursive: true } + ); + await fs.cp( + new URL('./api/assets/', outputDir), + new URL('./assets/', outputRoot), + { recursive: true } + ); + } + + // static assets / build + await fs.cp( + outputDir, + new URL('./.vercel/output/static/', projectDirectory), + { + recursive: true + } + ); +} + +const greenwoodPluginAdapterVercel = (options = {}) => [{ + type: 'adapter', + name: 'plugin-adapter-vercel', + provider: (compilation) => { + return async () => { + await vercelAdapter(compilation, options); + }; + } +}]; + +export { greenwoodPluginAdapterVercel }; \ No newline at end of file diff --git a/packages/plugin-adapter-vercel/test/cases/build.default/build.default.spec.js b/packages/plugin-adapter-vercel/test/cases/build.default/build.default.spec.js new file mode 100644 index 000000000..09aedd427 --- /dev/null +++ b/packages/plugin-adapter-vercel/test/cases/build.default/build.default.spec.js @@ -0,0 +1,265 @@ +/* + * Use Case + * Run Greenwood with the Vercel adapter plugin. + * + * User Result + * Should generate a static Greenwood build with serverless and edge functions output. + * + * User Command + * greenwood build + * + * User Config + * import { greenwoodPluginAdapterVercel } from '@greenwood/plugin-adapter-vercel'; + * + * { + * plugins: [{ + * greenwoodPluginAdapterVercel() + * }] + * } + * + * User Workspace + * package.json + * src/ + * api/ + * fragment.js + * greeting.js + * components/ + * card.js + * pages/ + * artists.js + * users.js + * services/ + * artists.js + * greeting.js + */ +import chai from 'chai'; +import fs from 'fs/promises'; +import glob from 'glob-promise'; +import { JSDOM } from 'jsdom'; +import path from 'path'; +import { checkResourceExists } from '../../../../cli/src/lib/resource-utils.js'; +import { getSetupFiles, getOutputTeardownFiles } from '../../../../../test/utils.js'; +import { normalizePathnameForWindows } from '../../../../cli/src/lib/resource-utils.js'; +import { Runner } from 'gallinago'; +import { fileURLToPath } from 'url'; + +const expect = chai.expect; + +describe('Build Greenwood With: ', function() { + const LABEL = 'Vercel Adapter plugin output'; + const cliPath = path.join(process.cwd(), 'packages/cli/src/index.js'); + const outputPath = fileURLToPath(new URL('.', import.meta.url)); + const vercelOutputFolder = new URL('./.vercel/output/', import.meta.url); + const vercelFunctionsOutputUrl = new URL('./functions/', vercelOutputFolder); + let runner; + + before(async function() { + this.context = { + publicDir: path.join(outputPath, 'public') + }; + runner = new Runner(); + }); + + describe(LABEL, function() { + before(async function() { + await runner.setup(outputPath, getSetupFiles(outputPath)); + await runner.runCommand(cliPath, 'build'); + }); + + describe('Default Output', function() { + let configFile; + let functionFolders; + + before(async function() { + configFile = await fs.readFile(new URL('./config.json', vercelOutputFolder), 'utf-8'); + functionFolders = await glob.promise(path.join(normalizePathnameForWindows(vercelFunctionsOutputUrl), '**/*.func')); + }); + + it('should output the expected number of serverless function output folders', function() { + expect(functionFolders.length).to.be.equal(4); + }); + + it('should output the expected configuration file for the build output', function() { + expect(configFile).to.be.equal('{"version":3}'); + }); + + it('should output the expected package.json for each serverless function', function() { + functionFolders.forEach(async (folder) => { + const packageJson = await fs.readFile(new URL('./package.json', `file://${folder}/`), 'utf-8'); + + expect(packageJson).to.be.equal('{"type":"module"}'); + }); + }); + + it('should output the expected .vc-config.json for each serverless function', function() { + functionFolders.forEach(async (folder) => { + const packageJson = await fs.readFile(new URL('./vc-config.json', `file://${folder}/`), 'utf-8'); + + expect(packageJson).to.be.equal('{"runtime":"nodejs18.x","handler":"index.js","launcherType":"Nodejs","shouldAddHelpers":true}'); + }); + }); + }); + + describe('Static directory output', function() { + it('should return the expected response when the serverless adapter entry point handler is invoked', async function() { + const publicFiles = await glob.promise(path.join(outputPath, 'public/**/**')); + + for (const file of publicFiles) { + const buildOutputDestination = file.replace(path.join(outputPath, 'public'), path.join(vercelOutputFolder.pathname, 'static')); + const itExists = await checkResourceExists(new URL(`file://${buildOutputDestination}`)); + + expect(itExists).to.be.equal(true); + } + }); + }); + + describe('Greeting API Route adapter', function() { + it('should return the expected response when the serverless adapter entry point handler is invoked', async function() { + const handler = (await import(new URL('./api/greeting.func/index.js', vercelFunctionsOutputUrl))).default; + const param = 'Greenwood'; + const response = { + headers: new Headers() + }; + + await handler({ + url: `http://localhost:8080/api/greeting?name=${param}`, + headers: { + host: 'http://localhost:8080' + } + }, { + status: function(code) { + response.status = code; + }, + send: function(body) { + response.body = body; + }, + setHeader: function(key, value) { + response.headers.set(key, value); + } + }); + const { status, body, headers } = response; + + expect(status).to.be.equal(200); + expect(headers.get('content-type')).to.be.equal('application/json'); + expect(JSON.parse(body).message).to.be.equal(`Hello ${param}!`); + }); + }); + + describe('Fragments API Route adapter', function() { + it('should return the expected response when the serverless adapter entry point handler is invoked', async function() { + const handler = (await import(new URL('./api/fragment.func/index.js', vercelFunctionsOutputUrl))).default; + const response = { + headers: new Headers() + }; + + await handler({ + url: 'http://localhost:8080/api/fragment', + headers: { + host: 'http://localhost:8080' + } + }, { + status: function(code) { + response.status = code; + }, + send: function(body) { + response.body = body; + }, + setHeader: function(key, value) { + response.headers.set(key, value); + } + }); + const { status, body, headers } = response; + const dom = new JSDOM(body); + const cardTags = dom.window.document.querySelectorAll('app-card'); + + expect(status).to.be.equal(200); + expect(cardTags.length).to.be.equal(2); + expect(headers.get('content-type')).to.be.equal('text/html'); + }); + }); + + describe('Artists SSR Page adapter', function() { + it('should return the expected response when the serverless adapter entry point handler is invoked', async function() { + const handler = (await import(new URL('./artists.func/index.js', vercelFunctionsOutputUrl))).default; + const response = { + headers: new Headers() + }; + const count = 2; + + await handler({ + url: 'http://localhost:8080/artists', + headers: { + host: 'http://localhost:8080' + } + }, { + status: function(code) { + response.status = code; + }, + send: function(body) { + response.body = body; + }, + setHeader: function(key, value) { + response.headers.set(key, value); + } + }); + + const { status, body, headers } = response; + const dom = new JSDOM(body); + const cardTags = dom.window.document.querySelectorAll('body > app-card'); + const headings = dom.window.document.querySelectorAll('body > h1'); + + expect(status).to.be.equal(200); + expect(cardTags.length).to.be.equal(count); + expect(headings.length).to.be.equal(1); + expect(headings[0].textContent).to.be.equal(`List of Artists: ${count}`); + expect(headers.get('content-type')).to.be.equal('text/html'); + }); + }); + + describe('Users SSR Page adapter', function() { + it('should return the expected response when the serverless adapter entry point handler is invoked', async function() { + const handler = (await import(new URL('./users.func/index.js', vercelFunctionsOutputUrl))).default; + const response = { + headers: new Headers() + }; + const count = 1; + + await handler({ + url: 'http://localhost:8080/users', + headers: { + host: 'http://localhost:8080' + } + }, { + status: function(code) { + response.status = code; + }, + send: function(body) { + response.body = body; + }, + setHeader: function(key, value) { + response.headers.set(key, value); + } + }); + + const { status, body, headers } = response; + const dom = new JSDOM(body); + const cardTags = dom.window.document.querySelectorAll('body > app-card'); + const headings = dom.window.document.querySelectorAll('body > h1'); + + expect(status).to.be.equal(200); + expect(cardTags.length).to.be.equal(count); + expect(headings.length).to.be.equal(1); + expect(headings[0].textContent).to.be.equal(`List of Users: ${count}`); + expect(headers.get('content-type')).to.be.equal('text/html'); + }); + }); + }); + + after(function() { + runner.teardown([ + path.join(outputPath, '.vercel'), + ...getOutputTeardownFiles(outputPath) + ]); + }); + +}); \ No newline at end of file diff --git a/packages/plugin-adapter-vercel/test/cases/build.default/greenwood.config.js b/packages/plugin-adapter-vercel/test/cases/build.default/greenwood.config.js new file mode 100644 index 000000000..3aba8de13 --- /dev/null +++ b/packages/plugin-adapter-vercel/test/cases/build.default/greenwood.config.js @@ -0,0 +1,7 @@ +import { greenwoodPluginAdapterVercel } from '../../../src/index.js'; + +export default { + plugins: [ + greenwoodPluginAdapterVercel() + ] +}; \ No newline at end of file diff --git a/packages/plugin-adapter-vercel/test/cases/build.default/src/api/fragment.js b/packages/plugin-adapter-vercel/test/cases/build.default/src/api/fragment.js new file mode 100644 index 000000000..c00251f91 --- /dev/null +++ b/packages/plugin-adapter-vercel/test/cases/build.default/src/api/fragment.js @@ -0,0 +1,27 @@ +import { renderFromHTML } from 'wc-compiler'; +import { getArtists } from '../services/artists.js'; + +export async function handler(request) { + const params = new URLSearchParams(request.url.slice(request.url.indexOf('?'))); + const offset = params.has('offset') ? parseInt(params.get('offset'), 10) : null; + const headers = new Headers({ 'Content-Type': 'text/html' }); + const artists = getArtists(offset); + const { html } = await renderFromHTML(` + ${ + artists.map((item, idx) => { + const { name, imageUrl } = item; + + return ` + + `; + }).join('') + } + `, [ + new URL('../components/card.js', import.meta.url) + ]); + + return new Response(html, { headers }); +} \ No newline at end of file diff --git a/packages/plugin-adapter-vercel/test/cases/build.default/src/api/greeting.js b/packages/plugin-adapter-vercel/test/cases/build.default/src/api/greeting.js new file mode 100644 index 000000000..c5b2f9ded --- /dev/null +++ b/packages/plugin-adapter-vercel/test/cases/build.default/src/api/greeting.js @@ -0,0 +1,14 @@ +import { getMessage } from '../services/message.js'; + +export async function handler(request) { + const params = new URLSearchParams(request.url.slice(request.url.indexOf('?'))); + const name = params.has('name') ? params.get('name') : 'World'; + const body = { message: getMessage(name) }; + const headers = new Headers(); + + headers.append('Content-Type', 'application/json'); + + return new Response(JSON.stringify(body), { + headers + }); +} \ No newline at end of file diff --git a/packages/plugin-adapter-vercel/test/cases/build.default/src/components/card.js b/packages/plugin-adapter-vercel/test/cases/build.default/src/components/card.js new file mode 100644 index 000000000..db51913ce --- /dev/null +++ b/packages/plugin-adapter-vercel/test/cases/build.default/src/components/card.js @@ -0,0 +1,27 @@ +export default class Card extends HTMLElement { + + selectArtist() { + alert(`selected artist is => ${this.getAttribute('title')}!`); + } + + connectedCallback() { + if (!this.shadowRoot) { + const thumbnail = this.getAttribute('thumbnail'); + const title = this.getAttribute('title'); + const template = document.createElement('template'); + + template.innerHTML = ` +
+

${title}

+ + +
+
+ `; + this.attachShadow({ mode: 'open' }); + this.shadowRoot.appendChild(template.content.cloneNode(true)); + } + } +} + +customElements.define('app-card', Card); \ No newline at end of file diff --git a/packages/plugin-adapter-vercel/test/cases/build.default/src/pages/artists.js b/packages/plugin-adapter-vercel/test/cases/build.default/src/pages/artists.js new file mode 100644 index 000000000..9a4887b16 --- /dev/null +++ b/packages/plugin-adapter-vercel/test/cases/build.default/src/pages/artists.js @@ -0,0 +1,25 @@ +import '../components/card.js'; +import { getArtists } from '../services/artists.js'; + +export default class ArtistsPage extends HTMLElement { + async connectedCallback() { + const artists = getArtists(); + const html = artists.map(artist => { + const { name, imageUrl } = artist; + + return ` + + + `; + }).join(''); + + this.innerHTML = ` + < Back +

List of Artists: ${artists.length}

+ ${html} + `; + } +} \ No newline at end of file diff --git a/packages/plugin-adapter-vercel/test/cases/build.default/src/pages/users.js b/packages/plugin-adapter-vercel/test/cases/build.default/src/pages/users.js new file mode 100644 index 000000000..007d96cf5 --- /dev/null +++ b/packages/plugin-adapter-vercel/test/cases/build.default/src/pages/users.js @@ -0,0 +1,27 @@ +import '../components/card.js'; + +export default class UsersPage extends HTMLElement { + async connectedCallback() { + const users = [{ + name: 'Foo', + thumbnail: 'foo.jpg' + }]; + const html = users.map(user => { + const { name, imageUrl } = user; + + return ` + + + `; + }).join(''); + + this.innerHTML = ` + < Back +

List of Users: ${users.length}

+ ${html} + `; + } +} \ No newline at end of file diff --git a/packages/plugin-adapter-vercel/test/cases/build.default/src/services/artists.js b/packages/plugin-adapter-vercel/test/cases/build.default/src/services/artists.js new file mode 100644 index 000000000..9d32ec97b --- /dev/null +++ b/packages/plugin-adapter-vercel/test/cases/build.default/src/services/artists.js @@ -0,0 +1,11 @@ +function getArtists() { + return [{ + name: 'Analog', + imageUrl: 'analog.png' + }, { + name: 'Fave', + imageUrl: 'fave.png' + }]; +} + +export { getArtists }; \ No newline at end of file diff --git a/packages/plugin-adapter-vercel/test/cases/build.default/src/services/message.js b/packages/plugin-adapter-vercel/test/cases/build.default/src/services/message.js new file mode 100644 index 000000000..3085719e8 --- /dev/null +++ b/packages/plugin-adapter-vercel/test/cases/build.default/src/services/message.js @@ -0,0 +1,7 @@ +function getMessage(name) { + return `Hello ${name}!`; +} + +export { + getMessage +}; \ No newline at end of file diff --git a/www/pages/plugins/custom-plugins.md b/www/pages/plugins/custom-plugins.md index 2653d2c73..711be0d2d 100644 --- a/www/pages/plugins/custom-plugins.md +++ b/www/pages/plugins/custom-plugins.md @@ -12,7 +12,8 @@ The available plugins built and maintained by Greenwood team and contributors ha | Name | Description | |---|---| -| [Adapter Netlify](https://github.com/ProjectEvergreen/greenwood/tree/master/packages/plugin-adapter-netlify) | Allows usage of [**Netlify**](https://www.netlify.com/) serverless and edge functions to deploy and host full stack Greenwood applications. | +| [Adapter Netlify](https://github.com/ProjectEvergreen/greenwood/tree/master/packages/plugin-adapter-netlify) | Allows usage of [**Netlify**](https://www.netlify.com/) static hosting with serverless and edge functions to deploy and host full stack Greenwood applications. | +| [Adapter Vercel](https://github.com/ProjectEvergreen/greenwood/tree/master/packages/plugin-adapter-vercel) | Allows usage of [**Vercel**](https://vercel.com/) static hosting with serverless and edge functions to deploy and host full stack Greenwood applications. | | [Babel](https://github.com/ProjectEvergreen/greenwood/tree/master/packages/plugin-babel) | Allows usage of [**Babel**](https://babeljs.io/) plugins, presets, and configuration in your project. | | [Google Analytics](https://github.com/ProjectEvergreen/greenwood/tree/master/packages/plugin-google-analytics) | Easily add usage tracking to your site with Google Analytics. | | [Include HTML](https://github.com/ProjectEvergreen/greenwood/tree/master/packages/plugin-include-html) | Provides additional methods for generating static HTML at build time, inspired by the original [HTML Imports spec](https://www.html5rocks.com/en/tutorials/webcomponents/imports/). |