Compare commits

...

27 Commits

Author SHA1 Message Date
daedalus
47ed9f40a8 3.0.1 2023-11-01 19:02:22 -04:00
daedalus
8eeac9a748 fix(docs quickstart): add required name and missing semi color 2023-11-01 19:02:22 -04:00
daedalus
ba64fede45
fix(LogListView): logs should be in desc order (#46) 2023-11-01 18:59:47 -04:00
daedalus
d185e7e420
chore(npm): remove docs from bundle (#44) 2023-09-19 21:00:06 -04:00
daedalus
608c2f3f2a
chore(release): 3.0.0 (#43) 2023-09-19 20:48:19 -04:00
daedalus
f59bfc8dcc chore(prettier): do not format docs dist 2023-09-19 20:46:19 -04:00
daedalus
9279a44fb1 fix(config): method type must be mixed for oneOf to be used 2023-09-19 20:46:19 -04:00
daedalus
eb5936fa2b feat: add remote docs 2023-09-19 20:46:19 -04:00
daedalus
74c9fb711c fix(config): allow build method to be defined 2023-09-19 20:46:19 -04:00
daedalus
84c0d4cda7 fix(config): ensure url,trigger and trigger type are required 2023-09-19 20:46:19 -04:00
daedalus
0fadeb8162 fix(config): allow wildcard for actions 2023-09-19 20:46:19 -04:00
daedalus
ec9ca19a1c refactor(build): add multi board support and simplify process 2023-09-19 20:46:19 -04:00
daedalus
0dd80139c3 refactor(services & controllers): use strapi naming convention 2023-09-19 20:46:19 -04:00
daedalus
9c18c7a184 refactor(services & controllers): use strapi naming convention 2023-09-19 20:46:19 -04:00
daedalus
f8d7860a0b refactor(log ct): use factories where applicable 2023-09-19 20:46:19 -04:00
daedalus
907759456b refactor(bootstrap): move bootstrap code under single folder
feat(boostrapEvents): add wildcard support for uid and actions
2023-09-19 20:46:19 -04:00
daedalus
0a446b5a18 feat(config): add multi build support 2023-09-19 20:46:19 -04:00
daedalus
c87758949d feat(admin): add multi build support
refactor(api): move api to hooks with single responsibility
refactor(admin) simplify pages
2023-09-19 20:46:19 -04:00
daedalus
a03218f61e refactor(components): move code out of index and use it for export
refactor(pluginId): move into common constant
2023-09-19 20:46:19 -04:00
daedalus
a047226662 chore: use npm over yarn
chore: update workflow to run on node 18
2023-09-19 20:46:19 -04:00
daedalus
706124c70b chore(lint & format): remove unnecessary rules and update paths 2023-09-19 20:46:19 -04:00
daedalus
e0b580d153 refactor(pkg): update and lock deps 2023-09-19 20:46:19 -04:00
daedalus
e4381771a1
chore(release): 2.2.3 (#41) 2023-07-31 23:29:32 -04:00
daedalus
04c9b89761
feat(event trigger): add event specific header support (#40)
Resolves #39
2023-07-28 16:55:22 -04:00
daedalus
11c260d7c1
docs(README): add clean rebuild instructions (#36)
Resolves #35
2023-02-26 11:53:18 -05:00
daedalus
554a1e34f4
chore(release): 2.2.2 (#33) 2022-12-07 21:47:24 -05:00
daedalus
ba4d4ac809
fix(engines): incorrect engine range (#32)
Resolves #31
2022-12-07 21:45:58 -05:00
72 changed files with 19639 additions and 2411 deletions

View File

@ -1,8 +1,8 @@
module.exports = {
files: ['./server/**/*'],
$schema: 'https://json.schemastore.org/eslintrc',
env: {
es6: true,
node: true,
},
extends: ['eslint:recommended', 'plugin:node/recommended', 'prettier'],
globals: {
strapi: 'readonly',
},
};

View File

@ -1,23 +1,15 @@
module.exports = {
files: ['./admin/**/*'],
$schema: 'https://json.schemastore.org/eslintrc',
parser: '@babel/eslint-parser',
env: {
browser: true,
es6: true,
},
plugins: ['react'],
extends: ['eslint:recommended', 'plugin:react/recommended', 'prettier'],
parserOptions: {
requireConfigFile: false,
ecmaVersion: 2018,
ecmaVersion: 'latest',
sourceType: 'module',
ecmaFeatures: {
jsx: true,
},
sourceType: 'module',
babelOptions: {
presets: ['@babel/preset-react'],
},
},
env: { browser: true, es6: true },
settings: {
react: {
version: 'detect',

View File

@ -1,28 +1,5 @@
const frontendESLint = require('./.eslintrc.frontend.js');
const backendESLint = require('./.eslintrc.backend.js');
module.exports = {
$schema: 'https://json.schemastore.org/eslintrc',
parserOptions: {
ecmaVersion: 2018,
},
rules: {
indent: ['error', 'tab'],
'linebreak-style': ['error', 'unix'],
quotes: ['error', 'single'],
semi: ['error', 'always'],
},
globals: {
strapi: 'readonly',
},
overrides: [
{
files: ['server/**/*'],
...backendESLint,
},
{
files: ['admin/**/*'],
...frontendESLint,
},
],
root: true,
overrides: [require('./.eslintrc.backend.js'), require('./.eslintrc.frontend.js')],
};

View File

@ -13,17 +13,14 @@ jobs:
- name: Checkout branch
uses: actions/checkout@v2
- name: Install Node v14
- name: Install Node v18
uses: actions/setup-node@v2
with:
node-version: '14.x'
node-version: '18.x'
registry-url: 'https://registry.npmjs.org'
- name: Install Yarn
run: npm install -g yarn
- name: Clean install deps
run: yarn install --frozen-lockfile
run: npm ci
- name: Publish to NPM
run: npm publish

4
.gitignore vendored
View File

@ -7,3 +7,7 @@ stats.json
.DS_Store
npm-debug.log
.idea
# docs
docs/.vitepress/cache
docs/.vitepress/dist

View File

@ -20,3 +20,6 @@ npm-debug.log
# github
.github
# docs
docs

View File

@ -1 +1,3 @@
package-lock.json
docs/.vitepress/cache
docs/.vitepress/dist

View File

@ -7,5 +7,5 @@
"useTabs": true,
"arrowParens": "always",
"endOfLine": "lf",
"printWidth": 100
"printWidth": 120
}

113
README.md
View File

@ -16,118 +16,9 @@ The installation requirements are the same as Strapi itself and can be found in
**NOTE**: While this plugin may work with the older Strapi versions, they are not supported, it is always recommended to use the latest version of Strapi.
## Installation
## Documentation
```sh
npm install strapi-plugin-website-builder
```
**or**
```sh
yarn add strapi-plugin-website-builder
```
## Configuration
The plugin configuration is stored in a config file located at `./config/plugins.js`.
The plugin has different structures depending on the type of trigger for the build. Each of the following sample configurations is the minimum needed for their respective trigger type.
### Manual Configuration
```javascript
module.exports = ({ env }) => ({
// ...
'website-builder': {
enabled: true,
config: {
url: 'https://link-to-hit-on-trigger.com',
trigger: {
type: 'manual',
},
}
},
// ...
});
```
### Cron/Periodic Configuration
```javascript
module.exports = ({ env }) => ({
// ...
'website-builder': {
enabled: true,
config: {
url: 'https://link-to-hit-on-trigger.com',
trigger: {
type: 'cron',
cron: '* * 1 * * *',
},
}
},
// ...
});
```
### Event Configuration
```javascript
module.exports = ({ env }) => ({
// ...
'website-builder': {
enabled: true,
config: {
url: 'https://link-to-hit-on-trigger.com',
trigger: {
type: 'event',
events: [
{
params: (record) => ({
id: `${record.id}_${record.title}`
}),
model: 'recipe',
types: ['create', 'delete'],
},
{
params: {
page: "home"
},
model: 'homepage',
types: ['update'],
},
],
},
}
},
// ...
});
```
**IMPORTANT NOTE**: Make sure any sensitive data is stored in env files.
#### The Complete Plugin Configuration Object
| Property | Description | Type | Required |
| -------- | ----------- | ---- | -------- |
| url | The trigger URL for the website build. | String | Yes |
| headers | Any headers to send along with the request. | Object | No |
| body | Any body data to send along with the request. | Object | No |
| trigger | The trigger conditions for the build. | Object | Yes |
| trigger.type | The type of trigger. The current supported options are `manual`,`cron` and `event` | String | Yes |
| trigger.cron | The cron expression to use for cron trigger. The supported expressions are the same as in the [strapi docs](https://docs.strapi.io/developer-docs/latest/setup-deployment-guides/configurations/optional/cronjobs.html#cron-jobs) | String | Only if the type is cron |
| trigger.events | The events to use for the event trigger. | Array | Only if the type is event |
| trigger.events.url | The model specific url to hit on event trigger. | String | No |
| trigger.events.params | The model specific params to add on event trigger. | Object `or` Function | No |
| trigger.events.model | The model to listen for events on. | String | Yes |
| trigger.events.types | The model events to trigger on. The current supported events are `create`, `update`, `delete`, `publish` and `unpublish`. Publish/Unpublish is only supported for non media models. | Array | Yes |
## Usage
Once the plugin has been installed and configured, it will show in the sidebar as `Website Builder`.
To trigger a manual build select the `Website Builder` menu item in the sidebar and click
the `Trigger Build` button to start a build process.
The documentation for this plugin can be viewed [here](https://strapi-plugin-website-builder.netlify.app/)
## Bugs

View File

@ -1,13 +0,0 @@
'use strict';
import { requestPluginEndpoint } from '../utils/requestPluginEndpoint';
const route = 'build';
const triggerBuild = () => {
return requestPluginEndpoint(route, {
method: 'POST',
});
};
export { triggerBuild };

View File

@ -1,24 +0,0 @@
import { requestPluginEndpoint } from '../utils/requestPluginEndpoint';
const route = 'logs';
const fetchBuildLogs = () => {
return requestPluginEndpoint(route);
};
const createBuildLog = ({ status }) => {
return requestPluginEndpoint('actions', {
method: 'POST',
body: {
status,
},
});
};
const deleteBuildLog = ({ id }) => {
return requestPluginEndpoint(`${route}/${id}`, {
method: 'DELETE',
});
};
export { fetchBuildLogs, deleteBuildLog, createBuildLog };

View File

@ -1,2 +0,0 @@
export * as build from './build';
export * as buildLogs from './buildLogs';

View File

@ -0,0 +1,26 @@
/**
*
* Initializer
*
*/
import { useEffect, useRef } from 'react';
import PropTypes from 'prop-types';
import { PLUGIN_ID } from '../../utils/constants';
const Initializer = ({ setPlugin }) => {
const ref = useRef();
ref.current = setPlugin;
useEffect(() => {
ref.current(PLUGIN_ID);
}, []);
return null;
};
Initializer.propTypes = {
setPlugin: PropTypes.func.isRequired,
};
export default Initializer;

View File

@ -1,26 +1 @@
/**
*
* Initializer
*
*/
import { useEffect, useRef } from 'react';
import PropTypes from 'prop-types';
import { pluginId } from '../../pluginId';
const Initializer = ({ setPlugin }) => {
const ref = useRef();
ref.current = setPlugin;
useEffect(() => {
ref.current(pluginId);
}, []);
return null;
};
Initializer.propTypes = {
setPlugin: PropTypes.func.isRequired,
};
export { Initializer };
export { default } from './Initializer';

View File

@ -1,27 +0,0 @@
import React from 'react';
import { Thead, Tr, Th } from '@strapi/design-system/Table';
import { Typography } from '@strapi/design-system/Typography';
import { useIntl } from 'react-intl';
import { getTrad } from '../../../utils/getTrad';
const headers = ['ID', 'Status', 'Trigger', 'Timestamp', 'Actions'];
export const LogTableHeaders = () => {
const { formatMessage } = useIntl();
return (
<Thead>
<Tr>
{headers.map((header, i) => (
<Th key={i}>
<Typography variant="sigma">
{formatMessage({
id: getTrad(`log-table.header.${header.toLowerCase()}`),
defaultMessage: header,
})}
</Typography>
</Th>
))}
</Tr>
</Thead>
);
};

View File

@ -1,60 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Tr, Td } from '@strapi/design-system/Table';
import { Typography } from '@strapi/design-system/Typography';
import { IconButton } from '@strapi/design-system/IconButton';
import Trash from '@strapi/icons/Trash';
import { useReactQuery } from '../../../hooks/useReactQuery';
const LogTableRow = ({ log }) => {
const { id, status, trigger, createdAt } = log;
const { buildLogMutations } = useReactQuery();
const handleBuildLogDelete = async (id) => {
try {
await buildLogMutations.delete.mutate({ id });
} catch (error) {
console.error(error);
}
};
const isSuccessFullBuild = status >= 200 && 400 > status;
return (
<Tr>
<Td>
<Typography textColor="neutral900">{id}</Typography>
</Td>
<Td>
<Typography textColor={isSuccessFullBuild ? 'success500' : 'danger500'}>
{status}
</Typography>
</Td>
<Td>
<Typography textColor="neutral900">{trigger}</Typography>
</Td>
<Td>
<Typography textColor="neutral900">{createdAt}</Typography>
</Td>
<Td>
<IconButton
onClick={() => handleBuildLogDelete(id)}
label="Delete"
noBorder
icon={<Trash />}
/>
</Td>
</Tr>
);
};
LogTableRow.propTypes = {
log: PropTypes.shape({
id: PropTypes.string.isRequired,
status: PropTypes.number.isRequired,
trigger: PropTypes.string.isRequired,
createdAt: PropTypes.string.isRequired,
}).isRequired,
};
export { LogTableRow };

View File

@ -1,20 +0,0 @@
import React from 'react';
import { Tr, Td } from '@strapi/design-system/Table';
import { NoContent } from '@strapi/helper-plugin';
const LogTableRowEmpty = () => {
return (
<Tr>
<Td colSpan={5}>
<NoContent
content={{
id: 'Settings.apiTokens.emptyStateLayout',
defaultMessage: 'No Build logs',
}}
/>
</Td>
</Tr>
);
};
export { LogTableRowEmpty };

View File

@ -1,25 +0,0 @@
import React from 'react';
import { Table, Tbody } from '@strapi/design-system/Table';
import { LogTableHeaders } from './LogTableHeaders';
import { LogTableRow } from './LogTableRow';
import { LogTableRowEmpty } from './LogTableRowEmpty';
import { useReactQuery } from '../../hooks/useReactQuery';
export const LogTable = () => {
const { buildLogQueries } = useReactQuery();
const { isLoading, data } = buildLogQueries.getBuildLogs();
return (
<Table>
<LogTableHeaders />
<Tbody>
{!isLoading && data.logs.length ? (
data.logs.map((log) => <LogTableRow key={log.id} log={log} />)
) : (
<LogTableRowEmpty />
)}
</Tbody>
</Table>
);
};

View File

@ -0,0 +1,14 @@
/**
*
* PluginIcon
*
*/
import React from 'react';
import Play from '@strapi/icons/Play';
const PluginIcon = () => {
return <Play />;
};
export default PluginIcon;

View File

@ -1,12 +1 @@
/**
*
* PluginIcon
*
*/
import React from 'react';
import Play from '@strapi/icons/Play';
const PluginIcon = () => <Play />;
export { PluginIcon };
export { default } from './PluginIcon';

View File

@ -0,0 +1,45 @@
import { useMutation, useQuery } from 'react-query';
import { useFetchClient, useNotification } from '@strapi/helper-plugin';
import { PLUGIN_ID } from '../utils/constants';
import { getTrad } from '../utils/common';
export const useBuild = () => {
const { post, get } = useFetchClient();
const toggleNotification = useNotification();
function getBuilds() {
return useQuery({
queryKey: [PLUGIN_ID, 'builds'],
queryFn: function () {
return get(`/${PLUGIN_ID}/builds`);
},
select: function ({ data }) {
return data.data || false;
},
});
}
const { mutateAsync: triggerBuild } = useMutation({
mutationFn: function (data) {
return post(`/${PLUGIN_ID}/builds`, { data });
},
onSuccess: () => {
toggleNotification({
type: 'success',
message: { id: getTrad('build.notification.trigger.success') },
});
},
onError: (error) => {
toggleNotification({
type: 'warning',
message: error.response?.error?.message || error.message || { id: 'notification.error' },
});
},
});
return {
triggerBuild,
getBuilds,
};
};

View File

@ -0,0 +1,47 @@
import { useQuery, useMutation, useQueryClient } from 'react-query';
import { useFetchClient, useNotification } from '@strapi/helper-plugin';
import { PLUGIN_ID } from '../utils/constants';
import { getTrad } from '../utils/common';
export const useLogs = () => {
const { get, del } = useFetchClient();
const toggleNotification = useNotification();
const queryClient = useQueryClient();
function getLogs({ page }) {
return useQuery({
queryKey: [PLUGIN_ID, 'logs'],
queryFn: function () {
return get(`/${PLUGIN_ID}/logs`, { params: { sort: ['id:desc'], pagination: { page } } });
},
select: function ({ data }) {
return { ...data } || false;
},
});
}
const { mutateAsync: deleteLog } = useMutation({
mutationFn: function (id) {
return del(`/${PLUGIN_ID}/logs/${id}`);
},
onSuccess: () => {
queryClient.invalidateQueries([PLUGIN_ID, 'logs']);
toggleNotification({
type: 'success',
message: { id: getTrad('log.notification.delete.success') },
});
},
onError: (error) => {
toggleNotification({
type: 'warning',
message: error.response?.error?.message || error.message || { id: 'notification.error' },
});
},
});
return {
getLogs,
deleteLog,
};
};

View File

@ -1,98 +0,0 @@
import { useQuery, useMutation, useQueryClient } from 'react-query';
import { useNotification } from '@strapi/helper-plugin';
import { build, buildLogs } from '../api';
import { getTrad } from '../utils/getTrad';
const { triggerBuild } = build;
const { fetchBuildLogs, createBuildLog, deleteBuildLog } = buildLogs;
const getQuerykey = ({ base }) => {
return [base];
};
const useReactQuery = () => {
const queryClient = useQueryClient();
const toggleNotification = useNotification();
// universal handlers
const handleError = (error) => {
const message = error.response ? error.response.error.message : error.message;
toggleNotification({
type: 'warning',
message,
});
};
const handleSuccess = ({ invalidate, notification }) => {
if (invalidate) {
queryClient.invalidateQueries(invalidate);
}
toggleNotification({
type: notification.type,
message: { id: getTrad(notification.tradId) },
});
};
// build
const buildMutations = {
create: useMutation(triggerBuild, {
onSuccess: () => {
const querykey = getQuerykey({
base: 'get-build-logs',
});
handleSuccess({
invalidate: querykey,
notification: {
type: 'success',
tradId: `build.notification.trigger.success`,
},
});
},
onError: (error) => handleError(error),
}),
};
// build logs
const buildLogQueries = {
getBuildLogs: (params) => {
const queryKey = getQuerykey({
base: 'get-build-logs',
});
return useQuery(queryKey, () => fetchBuildLogs(params).then((r) => r.data));
},
};
const buildLogMutations = {
delete: useMutation(deleteBuildLog, {
onSuccess: () => {
const querykey = getQuerykey({
base: 'get-build-logs',
});
handleSuccess({
invalidate: querykey,
notification: {
type: 'success',
tradId: `build-logs.notification.delete.success`,
},
});
},
onError: (error) => handleError(error),
}),
create: useMutation(createBuildLog, {
onSuccess: () => {
const querykey = getQuerykey({
base: 'get-build-logs',
});
handleSuccess({
invalidate: querykey,
});
},
onError: (error) => handleError(error),
}),
};
return { buildLogQueries, buildLogMutations, buildMutations };
};
export { useReactQuery };

View File

@ -1,48 +1,51 @@
import { prefixPluginTranslations } from '@strapi/helper-plugin';
import { pluginId } from './pluginId';
import { Initializer } from './components/Initializer';
import { PluginIcon } from './components/PluginIcon';
import pluginPkg from '../../package.json';
import { PLUGIN_ID } from './utils/constants';
import Initializer from './components/Initializer';
import PluginIcon from './components/PluginIcon';
const name = pluginPkg.strapi.displayName;
const name = 'Website Builder';
export default {
register(app) {
app.addMenuLink({
to: `/plugins/${pluginId}`,
to: `/plugins/${PLUGIN_ID}`,
icon: PluginIcon,
intlLabel: {
id: `${pluginId}.plugin.name`,
id: `${PLUGIN_ID}.plugin.name`,
defaultMessage: name,
},
Component: async () => {
const component = await import(/* webpackChunkName: "[request]" */ './pages/App');
const component = await import(/* webpackChunkName: "[website-builder-request]" */ './pages/App');
return component;
},
});
app.registerPlugin({
id: pluginId,
id: PLUGIN_ID,
initializer: Initializer,
isReady: false,
name,
});
},
async registerTrads({ locales }) {
const importedTrads = [];
for (const locale of locales) {
try {
const { default: data } = await import(`./translations/${locale}.json`);
importedTrads.push({
data: prefixPluginTranslations(data, pluginId),
const importedTrads = await Promise.all(
locales.map((locale) => {
return import(/* webpackChunkName: "translation-[website-builder-request]" */ `./translations/${locale}.json`)
.then(({ default: data }) => {
return {
data: prefixPluginTranslations(data, PLUGIN_ID),
locale,
};
})
.catch(() => {
return {
data: {},
locale,
};
});
} catch (error) {
importedTrads.push({ data: {}, locale });
}
}
})
);
return importedTrads;
return Promise.resolve(importedTrads);
},
};

View File

@ -8,17 +8,17 @@
import React from 'react';
import { Switch, Route } from 'react-router-dom';
import { NotFound } from '@strapi/helper-plugin';
import { pluginId } from '../../pluginId';
import HomePage from '../HomePage';
import { PLUGIN_ID } from '../../utils/constants';
import Builds from '../Builds/ListView';
import BuildLogs from '../Logs/ListView';
const App = () => {
return (
<div>
<Switch>
<Route path={`/plugins/${pluginId}`} component={HomePage} exact />
<Route path={`/plugins/${PLUGIN_ID}`} component={Builds} exact />
<Route path={`/plugins/${PLUGIN_ID}/logs`} component={BuildLogs} exact />
<Route component={NotFound} />
</Switch>
</div>
);
};

View File

@ -0,0 +1,167 @@
/*
*
* BuildPage
*
*/
import React, { memo, useState, useEffect } from 'react';
import { useIntl } from 'react-intl';
import {
Layout,
Main,
HeaderLayout,
ContentLayout,
Box,
Table,
Thead,
Th,
Tr,
Tbody,
Td,
EmptyStateLayout,
Typography,
Switch,
LinkButton,
Button,
Flex,
} from '@strapi/design-system';
import { EmptyDocuments, Stack, Play } from '@strapi/icons';
import { LoadingIndicatorPage } from '@strapi/helper-plugin';
import { PLUGIN_ID } from '../../../utils/constants';
import { useBuild } from '../../../hooks/useBuild';
import { getTrad } from '../../../utils/common';
const BuildPage = () => {
const [isLoading, setIsLoading] = useState(true);
const [builds, setBuilds] = useState([]);
const { formatMessage } = useIntl();
const { triggerBuild, getBuilds } = useBuild();
const { isLoading: isLoadingBuilds, data, isRefetching: isRefetchingBuilds } = getBuilds();
useEffect(() => {
setIsLoading(true);
if (!isLoadingBuilds && !isRefetchingBuilds) {
if (data) {
setBuilds(data);
}
setIsLoading(false);
}
}, [isLoadingBuilds, isRefetchingBuilds]);
function handleTriggerBuild(name) {
triggerBuild({ name });
}
return (
<Layout>
<Main aria-busy={isLoading}>
<HeaderLayout
title={formatMessage({ id: getTrad('builds.header.title'), defaultMessage: 'Builds' })}
primaryAction={
<LinkButton variant="secondary" size="s" endIcon={<Stack />} to={`/plugins/${PLUGIN_ID}/logs`}>
Logs
</LinkButton>
}
/>
<ContentLayout>
{isLoading ? (
<Box background="neutral0" padding={6} shadow="filterShadow" hasRadius>
<LoadingIndicatorPage />
</Box>
) : builds.length > 0 ? (
<Table colCount={5} rowCount={builds.length + 1}>
<Thead>
<Tr>
<Th width="5%">
<Typography variant="sigma" textColor="neutral600">
{formatMessage({
id: getTrad('builds.table.header.enabled'),
defaultMessage: 'Enabled',
})}
</Typography>
</Th>
<Th width="5%">
<Typography variant="sigma" textColor="neutral600">
{formatMessage({
id: getTrad('builds.table.header.trigger'),
defaultMessage: 'Trigger',
})}
</Typography>
</Th>
<Th width="20%">
<Typography variant="sigma" fontWeight="semiBold" textColor="neutral600">
{formatMessage({
id: getTrad('builds.table.header.name'),
defaultMessage: 'Name',
})}
</Typography>
</Th>
<Th width="60%">
<Typography variant="sigma" textColor="neutral600">
{formatMessage({
id: getTrad('builds.table.header.url'),
defaultMessage: 'URL',
})}
</Typography>
</Th>
<Th width="10%">
{formatMessage({
id: getTrad('table.header.actions'),
defaultMessage: 'Actions',
})}
</Th>
</Tr>
</Thead>
<Tbody>
{builds.map((build) => (
<Tr key={btoa(build.name)}>
<Td>
<Switch label="Build Enabled" selected={build.enabled} />
</Td>
<Td>
<Typography textColor="neutral800">{build.trigger.type}</Typography>
</Td>
<Td>
<Typography fontWeight="semiBold" textColor="neutral800">
{build.name}
</Typography>
</Td>
<Td>
<Typography textColor="neutral800">{build.url}</Typography>
</Td>
<Td>
<Flex gap={2}>
{build.trigger.type === 'manual' && (
<Button
variant="default"
size="S"
disabled={build.enabled === false}
endIcon={<Play />}
onClick={() => handleTriggerBuild(build.name)}
>
Trigger
</Button>
)}
</Flex>
</Td>
</Tr>
))}
</Tbody>
</Table>
) : (
<EmptyStateLayout
icon={<EmptyDocuments width="160px" />}
content={formatMessage({
id: getTrad('builds.empty'),
defaultMessage: 'No builds found',
})}
/>
)}
</ContentLayout>
</Main>
</Layout>
);
};
export default memo(BuildPage);

View File

@ -0,0 +1 @@
export { default } from './ListView';

View File

@ -1,12 +0,0 @@
import React from 'react';
import { ContentLayout } from '@strapi/design-system/Layout';
import { Stack } from '@strapi/design-system/Stack';
import { LogTable } from '../../../../components/LogTable';
export const HomeContentLayout = () => (
<ContentLayout>
<Stack size={4}>
<LogTable />
</Stack>
</ContentLayout>
);

View File

@ -1,22 +0,0 @@
import React from 'react';
import Publish from '@strapi/icons/Play';
import { HeaderLayout } from '@strapi/design-system/Layout';
import { Button } from '@strapi/design-system/Button';
import { useReactQuery } from '../../../../hooks/useReactQuery';
export const HomeHeaderLayout = () => {
const { buildMutations } = useReactQuery();
const handleTrigger = async () => buildMutations.create.mutate();
return (
<HeaderLayout
primaryAction={
<Button onClick={handleTrigger} variant="primary" startIcon={<Publish />} size="L">
Trigger Build
</Button>
}
title="Website Builder"
subtitle="The right way to build websites."
/>
);
};

View File

@ -1,21 +0,0 @@
/*
*
* HomePage
*
*/
import React, { memo } from 'react';
import { Box } from '@strapi/design-system/Box';
import { HomeHeaderLayout } from './components/HomeHeaderLayout';
import { HomeContentLayout } from './components/HomeContentLayout';
const HomePage = () => {
return (
<Box>
<HomeHeaderLayout />
<HomeContentLayout />
</Box>
);
};
export default memo(HomePage);

View File

@ -0,0 +1,167 @@
/*
*
* LogsPage
*
*/
import React, { memo, useState, useEffect } from 'react';
import { useIntl } from 'react-intl';
import {
Layout,
Main,
HeaderLayout,
ContentLayout,
Box,
Table,
Thead,
Th,
Tr,
Tbody,
Td,
EmptyStateLayout,
Typography,
VisuallyHidden,
IconButton,
} from '@strapi/design-system';
import { EmptyDocuments, Trash } from '@strapi/icons';
import { LoadingIndicatorPage } from '@strapi/helper-plugin';
import { useLogs } from '../../../hooks/useLogs';
import { getTrad } from '../../../utils/common';
const LogsPage = () => {
const [isLoading, setIsLoading] = useState(true);
const [logs, setLogs] = useState([]);
const { formatMessage } = useIntl();
const { getLogs, deleteLog } = useLogs();
const { isLoading: isLoadingLogs, data: response, isRefetching: isRefetchingLogs } = getLogs({ page: 1 });
useEffect(() => {
setIsLoading(true);
if (!isLoadingLogs && !isRefetchingLogs) {
if (response && !response.error) {
setLogs(response.data);
}
setIsLoading(false);
}
}, [isLoadingLogs, isRefetchingLogs]);
function isSuccessStatus(status) {
return status >= 200 && 400 > status;
}
function handleLogDelete(id) {
deleteLog(id);
}
return (
<Layout>
<Main aria-busy={isLoading}>
<HeaderLayout title={formatMessage({ id: getTrad('logs.header.title'), defaultMessage: 'Build Logs' })} />
<ContentLayout>
{isLoading ? (
<Box background="neutral0" padding={6} shadow="filterShadow" hasRadius>
<LoadingIndicatorPage />
</Box>
) : logs.length > 0 ? (
<>
<Table colCount={5} rowCount={logs.length + 1}>
<Thead>
<Tr>
<Th>
<Typography variant="sigma" textColor="neutral600">
{formatMessage({
id: getTrad('logs.table.header.id'),
defaultMessage: 'ID',
})}
</Typography>
</Th>
<Th>
<Typography variant="sigma" textColor="neutral600">
{formatMessage({
id: getTrad('logs.table.header.build'),
defaultMessage: 'Build',
})}
</Typography>
</Th>
<Th>
<Typography variant="sigma" textColor="neutral600">
{formatMessage({
id: getTrad('logs.table.header.trigger'),
defaultMessage: 'Trigger',
})}
</Typography>
</Th>
<Th>
<Typography variant="sigma" textColor="neutral600">
{formatMessage({
id: getTrad('logs.table.header.status'),
defaultMessage: 'Status',
})}
</Typography>
</Th>
<Th>
<Typography variant="sigma" fontWeight="semiBold" textColor="neutral600">
{formatMessage({
id: getTrad('logs.table.header.timestamp'),
defaultMessage: 'Timestamp',
})}
</Typography>
</Th>
<Th>
<VisuallyHidden>
{formatMessage({
id: getTrad('table.header.actions'),
defaultMessage: 'Actions',
})}
</VisuallyHidden>
</Th>
</Tr>
</Thead>
<Tbody>
{logs.map((log) => (
<Tr key={log.id}>
<Td>
<Typography textColor="neutral800">{log.id}</Typography>
</Td>
<Td>
<Typography textColor="neutral800">{log.attributes.trigger}</Typography>
</Td>
<Td>
<Typography textColor="neutral800">{log.attributes.build || 'unknown'}</Typography>
</Td>
<Td>
<Typography
fontWeight="semiBold"
textColor={isSuccessStatus(log.attributes.status) ? 'success500' : 'danger500'}
>
{log.attributes.status}
</Typography>
</Td>
<Td>
<Typography textColor="neutral800">{log.attributes.createdAt}</Typography>
</Td>
<Td>
<IconButton onClick={() => handleLogDelete(log.id)} label="Delete" noBorder icon={<Trash />} />
</Td>
</Tr>
))}
</Tbody>
</Table>
</>
) : (
<EmptyStateLayout
icon={<EmptyDocuments width="160px" />}
content={formatMessage({
id: getTrad('logs.empty'),
defaultMessage: 'No logs found',
})}
/>
)}
</ContentLayout>
</Main>
</Layout>
);
};
export default memo(LogsPage);

View File

@ -0,0 +1 @@
export { default } from './ListView';

View File

@ -1,3 +0,0 @@
import pluginPkg from '../../package.json';
export const pluginId = pluginPkg.strapi.name;

View File

@ -1,13 +1,18 @@
{
"log-table.header.id": "ID",
"log-table.header.status": "Status",
"log-table.header.trigger": "Trigger",
"log-table.header.createdAt": "Timestamp",
"log-table.header.actions": "Actions",
"home.header.actions.build": "Trigger Build",
"home.content.title": "Website Builder",
"home.content.subtitle": "The right way to build websites.",
"table.header.actions": "Actions",
"builds.header.title": "Builds",
"builds.table.header.enabled": "Enabled",
"builds.table.header.trigger": "Trigger",
"builds.table.header.name": "Timestamp",
"builds.table.header.url": "URL",
"builds.empty": "No builds found",
"build.notification.trigger.success": "Build has been triggered successfully",
"build-logs.notification.delete.success": "Build log deleted successfully",
"logs.header.title": "Build Logs",
"logs.table.header.id": "ID",
"logs.table.header.trigger": "Trigger",
"logs.table.header.status": "Status",
"logs.table.header.timestamp": "Timestamp",
"logs.empty": "No logs found",
"log.notification.delete.success": "Build log deleted successfully",
"plugin.name": "Website Builder"
}

View File

@ -0,0 +1,5 @@
import { PLUGIN_ID } from './constants';
export function getTrad(id) {
return `${PLUGIN_ID}.${id}`;
}

View File

@ -0,0 +1 @@
export const PLUGIN_ID = 'website-builder';

View File

@ -1,9 +0,0 @@
import { pluginId } from '../pluginId';
/**
* Auto prefix URLs with the plugin id
*
* @param {String} endpoint plugin specific endpoint
* @returns {String} plugin id prefixed endpoint
*/
export const getPluginEndpointURL = (endpoint) => `/${pluginId}/${endpoint}`;

View File

@ -1,3 +0,0 @@
import { pluginId } from '../pluginId';
export const getTrad = (id) => `${pluginId}.${id}`;

View File

@ -1,7 +0,0 @@
import { request } from '@strapi/helper-plugin';
import { getPluginEndpointURL } from './getPluginEndpointURL';
export const requestPluginEndpoint = (endpoint, data) => {
const url = getPluginEndpointURL(endpoint);
return request(url, data);
};

View File

@ -0,0 +1,28 @@
import { defineConfig } from 'vitepress';
// https://vitepress.dev/reference/site-config
export default defineConfig({
title: 'Strapi Plugin Website Builder',
description:
'A plugin for Strapi Headless CMS that provides the ability to trigger website builds manually, periodically or through model events.',
themeConfig: {
// https://vitepress.dev/reference/default-theme-config
nav: [
{ text: 'Home', link: '/' },
{ text: 'Quick Start', link: '/quick-start' },
{ text: 'Configuration API', link: '/config-api' },
],
sidebar: [
{
items: [
{ text: 'Quick Start', link: '/quick-start' },
{ text: 'Build Triggers', link: '/build-triggers' },
{ text: 'Configuration API', link: '/config-api' },
],
},
],
socialLinks: [{ icon: 'github', link: 'https://github.com/ComfortablyCoding/strapi-plugin-website-builder' }],
},
});

145
docs/build-triggers.md Normal file
View File

@ -0,0 +1,145 @@
# Builds
The plugin supports multiple builds and build configurations to cover as many use cases as possible. Each build at minimum must specify a name,url and trigger type to be valid.
Their are currently three supported trigger types `manual`,`cron`, and `event`.
## Manual Trigger
The manual trigger will start a build whenever the trigger button in the admin panel is pressed for the respective build.
```javascript
module.exports = ({ env }) => ({
// ...
'website-builder': {
enabled: true,
config: {
builds: [
{
name: 'production',
url: 'https://link-to-hit-on-trigger.com'
trigger: {
type: 'manual'
},
},
],
},
},
// ...
});
```
## Periodic Trigger
The periodic trigger will start a build at the interval specified by the cron expressions. The following example triggers a new build every hour.
```javascript
module.exports = ({ env }) => ({
// ...
'website-builder': {
enabled: true,
config: {
builds: [
{
name: 'production',
url: 'https://link-to-hit-on-trigger.com'
trigger: {
type: 'cron',
expression: '0 */1 * * *'
},
},
],
},
},
// ...
});
```
## Event Trigger
The event trigger wull start a build whenever one of the specific actions of the defined uid is emitted. The following example triggers a build every time a new article is created.
```javascript
module.exports = ({ env }) => ({
// ...
'website-builder': {
enabled: true,
config: {
builds: [
{
name: 'production',
url: 'https://link-to-hit-on-trigger.com'
trigger: {
type: 'event',
events: [
{
uid: 'api::articles.articles',
actions: ['create'],
},
],
},
},
],
},
},
// ...
});
```
::: info
To trigger builds for image mutations use the uid `plugin::upload.file`.
:::
## Multiple Builds
The plugin supports as many build configurations as you wish to use.
```javascript
module.exports = ({ env }) => ({
// ...
'website-builder': {
enabled: true,
config: {
builds: [
{
name: 'production-manual'
url: 'https://link-to-hit-on-trigger.com',
trigger: {
type: 'manual'
},
},
{
name: 'development',
enabled: env('NODE_ENV') !== 'production',
url: 'https://link-to-hit-on-trigger.com',
trigger: {
type: 'manual'
},
},
{
name: 'production-edge'
url: 'https://link-to-hit-on-trigger.com',
trigger: {
type: 'cron',
expression: '0 */1 * * *'
},
},
{
name: 'production-automated'
url: 'https://link-to-hit-on-trigger.com'
trigger: {
type: 'event',
events: [
{
uid: 'api::articles.articles',
actions: ['create'],
},
],
},
},
],
},
},
// ...
});
```

196
docs/config-api.md Normal file
View File

@ -0,0 +1,196 @@
# Configuration API
- default
```json
{
"shared": {},
"builds": [],
"hooks": {}
}
```
## shared
- type: `object`
> default values between all builds
- example
```json
{
"headers": {
"X-Powered-By": "Strapi CMS"
}
}
```
### headers
- type: `function` or `object`
- arguments: `{ record }`
- description: The default headers to be sent with the request for all builds. Any build specific headers with the same nproperty ame will override these. A record is only provided to the function for event triggers.
### params
- type: `function` or `object`
- arguments: `{ record }`
- description: The default parameters to be sent with the request for all builds. Any build specific params with the same property name will override these. A record is only provided to the function for event triggers.
## build
- type `array`
> build configurations
### name
- type: `string`
- description: The name for the build. **MUST BE UNIQUE**.
### url
- type: `string`
- description: The url for the build.
### trigger
- type: `object`
- description: The trigger configuration for the build.
- #### type
- type: `string`
- description: The trigger type for the build. It can be `manual`,`cron`, or `event`.
- #### expression
- type: `string`
- description: The cron expression to use for the `cron` tigger type. Required for the `cron` trigger type only.
- #### events
- type: `array`
- description: The events to trigger new build on. Required for the `event` trigger type only.
- ##### uid
- type: `string`
- description: The uid of the content type to watch mutations on.
- ##### actions
- type: `actions`
- description: The actions/mutations to listen for on the content type. Possible values are `create`,`update`,`delete`,`publish`,`unpublish`. `publish` and `unpublish` are only available on content types, not media mutations.
### method
- type: `string`
- default: `POST`
- description: The method to use for the request that triggers the build. Supported methods are `GET` and `POST`.
### headers
- type: `function` or `object`
- arguments: `{ record }`
- description: The headers to be sent with the request. A record is only provided to the function for event triggers.
- example
:::: code-group
```js [function]
{
headers: () => ({
'X-Powered-By': 'Lorem Ipsum',
});
}
```
```js [object]
{
headers: {
'X-Powered-By': 'Lorem Ipsum',
};
}
```
::::
### params
- type: `function` or `object`
- arguments: `{ record }`
- description: The parameters to be sent with the request. A record is only provided to the function for event triggers.
- example
:::: code-group
```js [function]
{
params: () => ({
lorem: 'ipsum',
});
}
```
```js [object]
{
params: {
'lorem': 'ipsum',
};
}
```
::::
### body
- type: `function` or `object`
- arguments: `{ record }`
- description: The parameters to be sent with the request. A record is only provided to the function for event triggers.
- example
:::: code-group
```js [function]
{
body: () => ({
lorem: 'ipsum',
});
}
```
```js [object]
{
body: {
'lorem': 'ipsum',
};
}
```
::::
## hooks
- type: `object`
> hook into specific areas to extend functionality
### beforeRequest
- type: `function`
- arguments: `requestConfig`
- description: Mutate the request before it is sent out
- example
```js
{
hooks: {
beforeRequest: (requestConfig) => {
requestConfig.headers.custom = 'custom_value';
return config;
};
}
}
```

23
docs/index.md Normal file
View File

@ -0,0 +1,23 @@
---
# https://vitepress.dev/reference/default-theme-home-page
layout: home
hero:
name: 'Strapi Plugin Website Builder'
tagline: 'The only builder you will ever need'
actions:
- theme: brand
text: Quick Start
link: /quick-start
- theme: alt
text: Configuration API
link: /config-api
features:
- title: Manual Builds
details: Trigger builds manually via the strapi admin.
- title: Periodic Builds
details: Trigger builds periodically through cron jobs.
- title: Events Builds
details: Trigger builds based on content type actions.
---

52
docs/quick-start.md Normal file
View File

@ -0,0 +1,52 @@
# Quick Start
## Installation & Configuration
::: info
This plugin is compatible with Strapi v4.x only. While it may work with the older Strapi versions, they are not supported. It is recommended to always use the latest version of Strapi.
:::
1. Install the plugin in the root directory of your strapi project.
:::: code-group
```bash [npm]
npm i strapi-plugin-website-builder
```
```bash [yarn]
yarn add strapi-plugin-website-builder
```
::::
2. Enable the plugin at `./config/plugins.js`.
```js
module.exports = ({ env }) => ({
// ...
'website-builder': {
enabled: true,
config: {
builds: [
{
name: 'manual-build',
url: 'https://link-to-hit-on-trigger.com',
trigger: {
type: 'manual',
},
},
],
},
},
// ...
});
```
## Usage
Once the plugin has been installed and configured, it should show in the sidebar as `Website Builder`. To trigger a manual build select the `Website Builder` menu item in the sidebar and click the `Trigger` button fr the build process you wish to start.
::: warning
If the plugin does not show in the admin sidebar after the plugin is enabled then a clean rebuild of the admin is required. This can be done by deleting the generated `.cache` and `build` folders and re-running the develop command.
:::

18297
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -1,11 +1,14 @@
{
"$schema": "https://json.schemastore.org/package",
"name": "strapi-plugin-website-builder",
"version": "2.2.1",
"version": "3.0.1",
"description": "A plugin for Strapi Headless CMS that provides the ability to trigger website builds manually, periodically or through model events.",
"scripts": {
"lint": "eslint . --fix",
"format": "prettier --write **/*.{ts,js,json,yml}"
"lint:fix": "eslint --fix \"./**/*.{js,yml}\"",
"format:fix": "prettier --write \"./**/*.{js,json,yml,md}\"",
"docs:dev": "vitepress dev docs",
"docs:build": "vitepress build docs",
"docs:preview": "vitepress preview docs"
},
"author": {
"name": "@ComfortablyCoding",
@ -25,23 +28,24 @@
"bugs": {
"url": "https://github.com/ComfortablyCoding/strapi-plugin-website-builder/issues"
},
"dependencies": {},
"dependencies": {
"axios": "^1.5.0",
"defu": "^6.1.2",
"lodash": "^4.17.21",
"react-query": "^3.39.3",
"react-router-dom": "^5.3.4",
"yup": "^0.32.9"
},
"devDependencies": {
"@babel/core": "^7.17.2",
"@babel/eslint-parser": "^7.17.0",
"@babel/preset-react": "^7.16.7",
"eslint": "^8.8.0",
"eslint-config-prettier": "^8.3.0",
"eslint-plugin-node": "^11.1.0",
"eslint-plugin-react": "^7.28.0",
"prettier": "^2.5.1"
"prettier": "^2.5.1",
"vitepress": "^1.0.0-rc.14"
},
"peerDependencies": {
"@strapi/strapi": "^4.0.7",
"axios": "^0.24.0",
"lodash": "^4.17.21",
"react-query": "^3.24.3",
"yup": "^0.32.9"
"@strapi/strapi": "^4.0.7"
},
"strapi": {
"displayName": "Website Builder",
@ -49,10 +53,6 @@
"name": "website-builder",
"kind": "plugin"
},
"engines": {
"node": ">=12.x.x <=16.x.x",
"npm": ">=6.0.0"
},
"keywords": [
"strapi",
"strapi-plugin",

20
server/bootstrap.js vendored
View File

@ -1,20 +0,0 @@
'use strict';
const { getPluginService } = require('./utils/getPluginService');
const { setupCronWebhook } = require('./utils/setupCronWebhook');
const { setupEventWebhook } = require('./utils/setupEventWebhook');
module.exports = async ({ strapi }) => {
const settings = getPluginService(strapi, 'settingsService').get();
// complete any required webhook setup
if (settings.trigger.type === 'cron') {
setupCronWebhook(strapi, settings);
strapi.log.info('[website builder] cron trigger enabled');
} else if (settings.trigger.type === 'event') {
setupEventWebhook(strapi, settings);
strapi.log.info('[website builder] event trigger enabled');
} else {
strapi.log.info('[website builder] manual trigger enabled');
}
};

20
server/bootstrap/bootstrap.js vendored Normal file
View File

@ -0,0 +1,20 @@
'use strict';
const { getService } = require('../utils/common');
const { bootstrapCron } = require('./bootstrapCron');
const { bootstrapEvents } = require('./bootstrapEvents');
module.exports = async ({ strapi }) => {
const builds = getService({ strapi, name: 'settings' }).get({ path: 'builds' });
builds
.filter((b) => b.enabled || typeof b.enabled === 'undefined')
.forEach((build) => {
if (build.trigger.type === 'cron') {
bootstrapCron({ strapi, build });
} else if (build.trigger.type === 'event') {
bootstrapEvents({ strapi, build });
}
strapi.log.info(`[website builder] ${build.trigger.type} trigger enabled for ${build.name} build`);
});
};

21
server/bootstrap/bootstrapCron.js vendored Normal file
View File

@ -0,0 +1,21 @@
'use strict';
const { getService } = require('../utils/common');
function bootstrapCron({ strapi, build }) {
// create cron check
strapi.cron.add({
[build.name]: {
options: {
rule: build.trigger.expression,
},
task: () => {
getService({ strapi, name: 'build' }).trigger({ name: build.name, trigger: { type: 'cron' } });
},
},
});
}
module.exports = {
bootstrapCron,
};

46
server/bootstrap/bootstrapEvents.js vendored Normal file
View File

@ -0,0 +1,46 @@
'use strict';
const { getService } = require('../utils/common');
const { FILE_UID, EMIT_ACTIONS } = require('../utils/constants');
function bootstrapEvents({ strapi, build }) {
// build valid events
const events = new Map();
build.trigger.events.forEach((event) => {
// setup actions
let actions = new Set();
if (event.actions === '*') {
EMIT_ACTIONS.forEach((action) => actions.add(action));
} else {
event.actions.forEach((action) => actions.add(action));
}
// setup events
if (event.uid === '*') {
Object.keys(strapi.contentTypes)
.filter((uid) => /^api::/.test(uid) || uid === FILE_UID)
.forEach((uid) => {
events.set(uid, actions);
});
} else {
events.set(event.uid, actions);
}
});
EMIT_ACTIONS.forEach((action) => {
strapi.eventHub.on(`entry.${action}`, ({ uid, entry: record }) => {
const entry = events.get(uid);
if (entry && entry.has(action)) {
getService({ strapi, name: 'build' }).trigger({
name: build.name,
record,
trigger: { type: 'event' },
});
}
});
});
}
module.exports = {
bootstrapEvents,
};

View File

@ -0,0 +1 @@
module.exports = require('./bootstrap');

View File

@ -3,5 +3,10 @@
const { pluginConfigSchema } = require('./schema.js');
module.exports = {
default: () => ({
shared: {},
builds: [],
hooks: {},
}),
validator: async (config) => await pluginConfigSchema.validate(config),
};

View File

@ -1,59 +1,72 @@
'use strict';
const yup = require('yup');
const { isURL } = require('../utils/isURL');
const pluginConfigSchema = yup
.object()
.shape({
url: yup
.string()
.test((value) => isURL(value))
.required('A valid url is required'),
headers: yup.object(),
body: yup.object(),
shared: yup.object(),
hooks: yup.object(),
builds: yup.array().of(
yup.object().shape({
enabled: yup.bool(),
name: yup.string().required('A build name must be provided'),
url: yup.string().required('A URL is required'),
trigger: yup
.object()
.shape({
type: yup.string().oneOf(['manual', 'cron', 'event']),
cron: yup.string().when('type', {
type: yup.string().oneOf(['manual', 'cron', 'event']).required('A trigger type is required'),
expression: yup.string().when('type', {
is: 'cron',
then: yup.string().required('A cron expression must be entered'),
}),
events: yup
.array()
.of(
yup.object().shape({
url: yup.string(),
params: yup.mixed().test({
name: 'params',
exclusive: true,
message: '${path} must be an object or function',
test: async (value) => {
if (typeof value !== 'function') {
const isObject = await yup.object().isValid(value);
return isObject;
}
return true;
},
}),
model: yup.string().required('A model name is required'),
types: yup
.array()
.of(yup.string().oneOf(['create', 'update', 'delete', 'publish', 'unpublish']))
.required('types is required'),
})
)
.when('type', {
events: yup.array().when('type', {
is: 'event',
then: yup
.array()
.of(
yup.object().shape({
uid: yup.string().required('A uid is required'),
actions: yup.mixed().test({
name: 'actions',
exclusive: true,
message: '${path} must be an string or valid actions',
test: (value) =>
typeof value === 'string' ||
yup
.array()
.of(yup.string().oneOf(['create', 'update', 'delete', 'publish', 'unpublish']))
.required('uid actions are required')
.isValid(value),
}),
})
)
.min(1, 'At least one event must be provided')
.required('events is required'),
}),
})
.required('trigger is required'),
.required('A trigger is required'),
method: yup.mixed().oneOf(['GET', 'POST']).optional(),
params: yup.mixed().test({
name: 'params',
exclusive: true,
message: '${path} must be an object or function',
test: (value) => typeof value === 'function' || yup.object().isValid(value),
}),
headers: yup.mixed().test({
name: 'headers',
exclusive: true,
message: '${path} must be an object or function',
test: (value) => typeof value === 'function' || yup.object().isValid(value),
}),
body: yup.mixed().test({
name: 'body',
exclusive: true,
message: '${path} must be an object or function',
test: (value) => typeof value === 'function' || yup.object().isValid(value),
}),
})
),
})
.required('A config is required');

View File

@ -18,16 +18,22 @@ module.exports = {
},
options: {
draftAndPublish: false,
comment: '',
},
attributes: {
status: {
type: 'integer',
},
build: {
type: 'string',
},
trigger: {
type: 'enumeration',
enum: ['manual', 'cron', 'event'],
default: 'manual',
type: 'string',
},
method: {
type: 'string',
},
response: {
type: 'json',
},
},
};

View File

@ -1,24 +1,32 @@
'use strict';
const { getPluginService } = require('../utils/getPluginService');
const { getService } = require('../utils/common');
module.exports = ({ strapi }) => ({
/**
* Trigger a website rebuild
* Trigger a website build
*
* @return {Object}
*/
async build(ctx) {
async trigger(ctx) {
try {
const settings = await getPluginService(strapi, 'settingsService').get();
const buildStatus = await getPluginService(strapi, 'buildService').build({
settings,
const { status } = await getService({ strapi, name: 'build' }).trigger({
name: ctx.request.body.data.name,
trigger: { type: 'manual' },
});
ctx.send({ data: { status: buildStatus } });
ctx.send({ data: { status } });
} catch (error) {
ctx.badRequest();
}
},
/**
* Get all builds
*
* @return {Object}
*/
async find(ctx) {
ctx.send({ data: getService({ strapi, name: 'settings' }).get({ path: 'builds' }) });
},
});

View File

@ -1,9 +1,9 @@
'use strict';
const logController = require('./log-controller');
const buildController = require('./build-controller');
const log = require('./log-controller');
const build = require('./build-controller');
module.exports = {
logController,
buildController,
log,
build,
};

View File

@ -1,48 +1,9 @@
'use strict';
const { getPluginService } = require('../utils/getPluginService');
module.exports = ({ strapi }) => ({
/**
* Fetch the current logs
*
* @return {Array} logs
/**
* controller
*/
async find(ctx) {
const logs = await getPluginService(strapi, 'logService').find({
sort: { createdAt: 'DESC' },
});
ctx.send({ data: { logs } });
},
const { createCoreController } = require('@strapi/strapi').factories;
/**
* Create a log
*
* @return {Object} log
*/
async create(ctx) {
const { body } = ctx.request;
const createdLog = await getPluginService(strapi, 'logService').create(body);
ctx.send({ data: { log: createdLog } });
},
/**
* Delete a log
*
* @return {Object} log
*/
async delete(ctx) {
const { id } = ctx.params;
const log = await getPluginService(strapi, 'logService').findOne(id);
if (!log) {
return ctx.notFound('log not found');
}
const deletedLog = await getPluginService(strapi, 'logService').delete(id);
ctx.send({ data: { log: deletedLog } });
},
});
module.exports = createCoreController('plugin::website-builder.log');

View File

@ -3,7 +3,12 @@
module.exports = [
{
method: 'POST',
path: '/build',
handler: 'buildController.build',
path: '/builds',
handler: 'build.trigger',
},
{
method: 'GET',
path: '/builds',
handler: 'build.find',
},
];

View File

@ -4,16 +4,11 @@ module.exports = [
{
method: 'GET',
path: '/logs',
handler: 'logController.find',
},
{
method: 'POST',
path: '/logs',
handler: 'logController.create',
handler: 'log.find',
},
{
method: 'DELETE',
path: '/logs/:id',
handler: 'logController.delete',
handler: 'log.delete',
},
];

View File

@ -1,81 +1,32 @@
'use strict';
const axios = require('axios').default;
const { getPluginService } = require('../utils/getPluginService');
const { getService } = require('../utils/common');
module.exports = ({ strapi }) => ({
buildRequestConfigParams(params, record) {
if (typeof params !== 'function') {
return params;
}
return params(record);
},
/**
* Builds the build request configuration
*
* @param {object} options
* @param {object} options.settings The plugin setting
* @param {string} options.trigger The type of trigger that started the build
*
* @return {object} requestConfig The request configuration for the build request
*/
buildRequestConfig({ settings, trigger, record }) {
let requestConfig = { method: 'POST', data: {}, url: settings.url };
if (settings.headers) {
requestConfig.headers = settings.headers;
}
async trigger({ name, record, trigger }) {
let log = { trigger: trigger.type, status: 500, build: name };
if (settings.body) {
requestConfig.data = settings.body;
}
// check any event settings overrides for the event model
if (trigger.type !== 'event') {
return requestConfig;
}
const eventSettings = settings.trigger.events.find((e) => e.model === trigger.data.model);
if (!eventSettings) {
return requestConfig;
}
if (eventSettings.url) {
requestConfig.url = eventSettings.url;
}
if (eventSettings.params) {
requestConfig.params = this.buildRequestConfigParams(eventSettings.params, record);
}
return requestConfig;
},
/**
* Makes a request to the url specified in the plugin config
*
* @param {object} options
* @param {object} options.settings The plugin setting
* @param {string} options.trigger The type of trigger that started the build
*
* @return {Promise<object>} response The response data from the url
*/
async build({ record, settings, trigger }) {
let status = 500;
try {
let requestConfig = this.buildRequestConfig({ settings, trigger, record });
const buildResponse = await axios(requestConfig);
status = buildResponse.status;
const request = await getService({ strapi, name: 'request' }).build({
name,
record,
trigger,
});
const response = await getService({ strapi, name: 'request' }).execute(request);
log.status = response.status;
log.response = response.data;
} catch (error) {
if (error.response) {
status = error.response.status;
log.status = error.response.status;
} else if (error.request) {
log.response = {};
} else {
log.response = error.message;
}
} finally {
getPluginService(strapi, 'logService').create({
trigger: trigger.type,
status,
timestamp: Date.now(),
});
}
return { status };
getService({ strapi, name: 'log' }).create({ data: log });
return { status: log.status };
},
});

View File

@ -1,11 +1,13 @@
'use strict';
const logService = require('./log-service');
const buildService = require('./build-service');
const settingsService = require('./settings-service');
const log = require('./log-service');
const build = require('./build-service');
const settings = require('./settings-service');
const request = require('./request-service');
module.exports = {
logService,
buildService,
settingsService,
log,
build,
settings,
request,
};

View File

@ -1,43 +1,9 @@
'use strict';
const { pluginId } = require('../utils/pluginId');
const uid = `plugin::${pluginId}.log`;
module.exports = ({ strapi }) => ({
/**
* Returns the currently stored build logs
*
* @return {Promise<array>} logs
/**
* service
*/
find(options = {}) {
return strapi.entityService.findMany(uid, options);
},
/**
* Returns the a specific stored build log
*
* @return {Promise<Object>} log
*/
findOne(id, options = {}) {
return strapi.entityService.findOne(uid, id, options);
},
const { createCoreService } = require('@strapi/strapi').factories;
/**
* Create a build log
*
* @return {Promise<Object>} log
*/
create(log) {
return strapi.entityService.create(uid, { data: log });
},
/**
* Deletes a build log
*
* @return {Promise<Object>} log
*/
delete(id) {
return strapi.entityService.delete(uid, id);
},
});
module.exports = createCoreService('plugin::website-builder.log');

View File

@ -0,0 +1,61 @@
'use strict';
const axios = require('axios').default;
const { defu } = require('defu');
const { getService, resolveValue } = require('../utils/common');
module.exports = ({ strapi }) => ({
async build({ name, record, trigger }) {
let request = {};
const { shared, builds } = getService({ strapi, name: 'settings' }).get();
const build = builds.find((b) => b.name === name);
if (!build) {
return;
}
// method
request.method = build.method || 'POST';
// url
request.url = build.url;
// body
if (build.body) {
request.data = await resolveValue({ value: build.body, args: { record, trigger } });
}
// params
if (shared.params) {
request.params = await resolveValue({ value: shared.params, args: { record, trigger } });
}
if (build.params) {
const paramsValue = await resolveValue({ value: build.params, args: { record, trigger } });
request.params = defu(paramsValue, request.params || {});
}
// headers
if (shared.headers) {
request.headers = await resolveValue({ value: shared.headers, args: { record, trigger } });
}
if (build.headers) {
const headerValue = await resolveValue({ value: build.headers, args: { record, trigger } });
request.headers = defu(headerValue, request.headers || {});
}
return request;
},
async execute(request) {
const hooks = getService({ strapi, name: 'settings' }).get({ path: 'hooks' });
if (hooks.beforeRequest) {
axios.interceptors.request.use(hooks.beforeRequest);
}
return axios(request);
},
});

View File

@ -1,14 +1,16 @@
'use strict';
const { pluginId } = require('../utils/pluginId');
const { PLUGIN_ID } = require('../utils/constants');
module.exports = ({ strapi }) => ({
/**
* Returns the current plugin settings
*
* @return {Promise<Object>} settings
*/
get() {
return strapi.config.get(`plugin.${pluginId}`);
get({ path = '', defaultValue } = {}) {
if (path.length) {
path = `.${path}`;
}
return strapi.config.get(`plugin.${PLUGIN_ID}${path}`, defaultValue);
},
set({ path = '', value }) {
return strapi.config.set(`plugin.website-builder${path}`, value);
},
});

18
server/utils/common.js Normal file
View File

@ -0,0 +1,18 @@
'use strict';
function getService({ strapi, name, plugin = 'website-builder' }) {
return strapi.plugin(plugin).service(name);
}
async function resolveValue({ value, args }) {
if (typeof value === 'function') {
return await value(args);
}
return value;
}
module.exports = {
getService,
resolveValue,
};

11
server/utils/constants.js Normal file
View File

@ -0,0 +1,11 @@
const PLUGIN_ID = 'website-builder';
const FILE_UID = 'plugin::upload.file';
const EMIT_ACTIONS = ['create', 'update', 'delete', 'publish', 'unpublish'];
module.exports = {
PLUGIN_ID,
FILE_UID,
EMIT_ACTIONS,
};

View File

@ -1,12 +0,0 @@
const { pluginId } = require('./pluginId');
/**
* A helper function to obtain a plugin service
*
* @return service
*/
const getPluginService = (strapi, name) => strapi.plugin(pluginId).service(name);
module.exports = {
getPluginService,
};

View File

@ -1,7 +0,0 @@
'use strict';
const isURL = (url) => /^(http|https):\/\/(www.)?/.test(url);
module.exports = {
isURL,
};

View File

@ -1,10 +0,0 @@
const pluginPkg = require('../../package.json');
/**
* Returns the plugin id
*
* @return plugin id
*/
const pluginId = pluginPkg.strapi.name;
module.exports = { pluginId };

View File

@ -1,17 +0,0 @@
const { getPluginService } = require('./getPluginService');
/**
* Setup function for cron type trigger
*
*/
const setupCronWebhook = (strapi, settings) => {
strapi.cron.add({
[settings.trigger.cron]: ({ strapi }) => {
getPluginService(strapi, 'buildService').build({ settings, trigger: { type: 'cron' } });
},
});
};
module.exports = {
setupCronWebhook,
};

View File

@ -1,53 +0,0 @@
'use strict';
const { getPluginService } = require('./getPluginService');
const normalizeEvents = (events) => {
let eventModels = {};
for (const { model, types } of events) {
let eventPrefix = 'entry';
// media model uses the media prefix for events
if (model === 'media') {
eventPrefix = 'media';
}
// add each model to their respective event type
for (const eventType of types) {
const eventKey = `${eventPrefix}.${eventType}`;
if (!eventModels[eventKey]) {
eventModels[eventKey] = [];
}
eventModels[eventKey].push(model);
}
}
return eventModels;
};
/**
* Setup function for event type trigger
*
*/
const setupEventWebhook = (strapi, settings) => {
const events = normalizeEvents(settings.trigger.events);
for (const [event, eventModels] of Object.entries(events)) {
strapi.eventHub.on(event, (data) => {
// build on data.media (nothing to verify since it wouldn't get here without having the event setup on the event listener)
// otherwise, build on matching models
if (data.media || eventModels.includes(data.model)) {
getPluginService(strapi, 'buildService').build({
record: data.entry,
settings,
trigger: { type: 'event', data: { type: event, ...data } },
});
}
});
}
};
module.exports = {
setupEventWebhook,
};

1452
yarn.lock

File diff suppressed because it is too large Load Diff