basebrick-bricklayer 1.1.0 ā 1.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +29 -0
- package/bin/bricklayer.js +15 -1
- package/bin/deploy.js +93 -0
- package/bin/init.js +15 -8
- package/bin/manage.js +97 -2
- package/history.md +7 -0
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -23,6 +23,18 @@ npx bricklayer init
|
|
|
23
23
|
|
|
24
24
|
This interactive setup will create the default folder structure and ask if you'd like to scaffold a demo site with starter templates, as well as configure Sonic JS CMS integration automatically.
|
|
25
25
|
|
|
26
|
+
## Automated Deployment (GitHub Actions)
|
|
27
|
+
|
|
28
|
+
If you chose to generate a `deploy.yml` workflow during initialization, Bricklayer will automatically deploy your site to Cloudflare on every push to the `main` branch.
|
|
29
|
+
|
|
30
|
+
For Wrangler to authenticate correctly in a non-interactive CI/CD environment, you **must** configure your repository secrets.
|
|
31
|
+
|
|
32
|
+
1. Go to [Cloudflare API Tokens](https://developers.cloudflare.com/fundamentals/api/get-started/create-token/) and create an API Token with `Edit Cloudflare Workers` permissions.
|
|
33
|
+
2. In your GitHub repository, navigate to **Settings > Secrets and variables > Actions**.
|
|
34
|
+
3. Create two new Repository Secrets:
|
|
35
|
+
- `CLOUDFLARE_API_TOKEN`: Your newly created API token.
|
|
36
|
+
- `CLOUDFLARE_ACCOUNT_ID`: Your Cloudflare Account ID (found on your Cloudflare dashboard overview).
|
|
37
|
+
|
|
26
38
|
Alternatively, if you are working within the BaseBrick ecosystem locally, you can link it:
|
|
27
39
|
|
|
28
40
|
```bash
|
|
@@ -99,6 +111,23 @@ Bricklayer natively supports reading `.env` and `.dev.vars` (Cloudflare Pages) f
|
|
|
99
111
|
- **Nunjucks Templates:** Variables are automatically passed to Nunjucks templates under the global `env` object. E.g., `{{ env.MY_API_KEY }}`.
|
|
100
112
|
- **CMS Configuration:** Environment variables can be directly interpolated in `generic.json` strings using the `${VARIABLE_NAME}` syntax.
|
|
101
113
|
|
|
114
|
+
## Bricklayer Manager
|
|
115
|
+
|
|
116
|
+
Bricklayer includes a centralized dashboard to help you track and administer all your statically generated sites.
|
|
117
|
+
|
|
118
|
+
The **Bricklayer Manager** allows you to:
|
|
119
|
+
- View all deployed Bricklayer sites via an elegant web UI.
|
|
120
|
+
- Automatically track and store Cloudflare Worker endpoints.
|
|
121
|
+
- Access deep links directly to Cloudflare Dashboards for easy debugging.
|
|
122
|
+
|
|
123
|
+
You can find the source code and instructions for deploying your own manager instance in the `manager/` directory of the core [BrickLayer GitHub Repository](https://github.com/cryptoskillz/BrickLayer/tree/main/manager).
|
|
124
|
+
|
|
125
|
+
Once your manager is deployed, use the CLI command to securely sync any of your projects:
|
|
126
|
+
|
|
127
|
+
```bash
|
|
128
|
+
bricklayer manage
|
|
129
|
+
```
|
|
130
|
+
|
|
102
131
|
## Directory Structure
|
|
103
132
|
|
|
104
133
|
Bricklayer expects the following default structure (overridable via options):
|
package/bin/bricklayer.js
CHANGED
|
@@ -15,6 +15,7 @@ Commands:
|
|
|
15
15
|
(empty) Run a standard development build
|
|
16
16
|
init Scaffold a new project (Demo, CMS, Cloudflare API)
|
|
17
17
|
manage Register this project with a Bricklayer Manager
|
|
18
|
+
deploy Deploy the project to Cloudflare and register its URL
|
|
18
19
|
help Show this help message
|
|
19
20
|
|
|
20
21
|
Options:
|
|
@@ -36,8 +37,21 @@ Generated NPM Scripts (after init):
|
|
|
36
37
|
|
|
37
38
|
if (args.includes('init')) {
|
|
38
39
|
initProject(process.cwd()).catch(console.error);
|
|
40
|
+
} else if (args.includes('deploy')) {
|
|
41
|
+
const preview = args.includes('--preview');
|
|
42
|
+
import('./deploy.js').then(m => m.deployProject(process.cwd(), { preview })).catch(console.error);
|
|
39
43
|
} else if (args.includes('manage')) {
|
|
40
|
-
|
|
44
|
+
const reconfigure = args.includes('-c') || args.includes('--config');
|
|
45
|
+
let url = null;
|
|
46
|
+
let token = null;
|
|
47
|
+
|
|
48
|
+
const uIndex = args.findIndex(a => a === '-u' || a === '--url');
|
|
49
|
+
if (uIndex !== -1 && args[uIndex + 1]) url = args[uIndex + 1];
|
|
50
|
+
|
|
51
|
+
const tIndex = args.findIndex(a => a === '-t' || a === '--token');
|
|
52
|
+
if (tIndex !== -1 && args[tIndex + 1]) token = args[tIndex + 1];
|
|
53
|
+
|
|
54
|
+
import('./manage.js').then(m => m.manageProject(process.cwd(), { reconfigure, url, token })).catch(console.error);
|
|
41
55
|
} else {
|
|
42
56
|
const isProd = args.includes('--prod');
|
|
43
57
|
buildSite({ isProd, cwd: process.cwd() })
|
package/bin/deploy.js
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { spawn } from 'child_process';
|
|
4
|
+
|
|
5
|
+
export async function deployProject(cwd, options = {}) {
|
|
6
|
+
const isPreview = options.preview || false;
|
|
7
|
+
|
|
8
|
+
console.log(`\nš Deploying to Cloudflare ${isPreview ? 'Preview' : 'Production'}...\n`);
|
|
9
|
+
|
|
10
|
+
const args = ['deploy'];
|
|
11
|
+
if (isPreview) {
|
|
12
|
+
args.push('--env', 'preview');
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const tmpLog = path.join(cwd, '.wrangler-deploy.log');
|
|
16
|
+
|
|
17
|
+
let commandStr;
|
|
18
|
+
if (process.platform === 'darwin') {
|
|
19
|
+
commandStr = `script -q ${tmpLog} npx wrangler ${args.join(' ')}`;
|
|
20
|
+
} else if (process.platform === 'linux') {
|
|
21
|
+
commandStr = `script -q -c "npx wrangler ${args.join(' ')}" ${tmpLog}`;
|
|
22
|
+
} else {
|
|
23
|
+
// Windows fallback
|
|
24
|
+
commandStr = `npx wrangler ${args.join(' ')} > ${tmpLog} 2>&1`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const child = spawn(commandStr, {
|
|
28
|
+
cwd,
|
|
29
|
+
stdio: 'inherit',
|
|
30
|
+
shell: true
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
child.on('close', async (code) => {
|
|
34
|
+
let outputData = '';
|
|
35
|
+
if (fs.existsSync(tmpLog)) {
|
|
36
|
+
outputData = fs.readFileSync(tmpLog, 'utf8');
|
|
37
|
+
// Remove ANSI color codes
|
|
38
|
+
outputData = outputData.replace(/\x1B\[[0-9;]*[a-zA-Z]/g, '');
|
|
39
|
+
// Clean up the log file
|
|
40
|
+
try { fs.unlinkSync(tmpLog); } catch (e) {}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (code === 0) {
|
|
44
|
+
// Try to extract URL from wrangler output
|
|
45
|
+
const urlMatch = outputData.match(/https:\/\/[a-zA-Z0-9-]+\.[a-zA-Z0-9-]+\.workers\.dev/i) ||
|
|
46
|
+
outputData.match(/https:\/\/[a-zA-Z0-9-]+\.[a-zA-Z0-9-]+\.pages\.dev/i) ||
|
|
47
|
+
outputData.match(/https:\/\/[a-zA-Z0-9-]+\.[a-zA-Z0-9-]+\.cloudflare\.com/i) ||
|
|
48
|
+
outputData.match(/(https:\/\/[^\s"]+)/i);
|
|
49
|
+
|
|
50
|
+
let deployedUrl = null;
|
|
51
|
+
const lines = outputData.split('\n');
|
|
52
|
+
for (const line of lines) {
|
|
53
|
+
if (line.includes('workers.dev') || line.includes('pages.dev')) {
|
|
54
|
+
const match = line.match(/(https:\/\/[^\s"]+)/i);
|
|
55
|
+
if (match) deployedUrl = match[1];
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Fallback to the first URL we found if we didn't find a worker specific one
|
|
60
|
+
if (!deployedUrl && urlMatch) {
|
|
61
|
+
deployedUrl = urlMatch[1] || urlMatch[0];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (deployedUrl) {
|
|
65
|
+
console.log(`\nā
Deployed successfully to: ${deployedUrl}`);
|
|
66
|
+
|
|
67
|
+
const localConfigPath = path.join(cwd, '.basebrick.config');
|
|
68
|
+
if (fs.existsSync(localConfigPath)) {
|
|
69
|
+
try {
|
|
70
|
+
const projectConfig = JSON.parse(fs.readFileSync(localConfigPath, 'utf8'));
|
|
71
|
+
projectConfig.url = deployedUrl;
|
|
72
|
+
projectConfig.environment = isPreview ? 'Preview' : 'Production';
|
|
73
|
+
|
|
74
|
+
fs.writeFileSync(localConfigPath, JSON.stringify(projectConfig, null, 2));
|
|
75
|
+
console.log('ā
Added deployed URL to .basebrick.config');
|
|
76
|
+
|
|
77
|
+
// Automatically push the new config to the manager
|
|
78
|
+
console.log('\nš Automatically registering new URL with Bricklayer Manager...');
|
|
79
|
+
const manageModule = await import('./manage.js');
|
|
80
|
+
await manageModule.manageProject(cwd, {});
|
|
81
|
+
} catch(e) {
|
|
82
|
+
console.error('ā Failed to update .basebrick.config with URL:', e);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
} else {
|
|
86
|
+
console.log('\nā
Deployed successfully (Could not extract URL automatically)');
|
|
87
|
+
}
|
|
88
|
+
} else {
|
|
89
|
+
console.error(`\nā Deployment failed with exit code ${code}`);
|
|
90
|
+
process.exit(1);
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
}
|
package/bin/init.js
CHANGED
|
@@ -35,6 +35,9 @@ export async function initProject(cwd) {
|
|
|
35
35
|
const projectNameRaw = await question('What is the name of your project? (bricklayer-site) ');
|
|
36
36
|
const projectName = projectNameRaw.trim() || 'bricklayer-site';
|
|
37
37
|
|
|
38
|
+
const projectDescriptionRaw = await question('Project description (optional): ');
|
|
39
|
+
const projectDescription = projectDescriptionRaw.trim() || '';
|
|
40
|
+
|
|
38
41
|
const githubRepoRaw = await question('What is the GitHub repository URL? (leave blank for none) ');
|
|
39
42
|
const githubRepo = githubRepoRaw.trim();
|
|
40
43
|
|
|
@@ -100,7 +103,7 @@ export async function initProject(cwd) {
|
|
|
100
103
|
// 2. Create base CSS
|
|
101
104
|
const cssPath = path.join(cwd, 'src/assets/tailwind/input.css');
|
|
102
105
|
if (!fs.existsSync(cssPath)) {
|
|
103
|
-
fs.writeFileSync(cssPath, `@
|
|
106
|
+
fs.writeFileSync(cssPath, `@import "tailwindcss";\n\n@source "../../**/*.njk";\n`);
|
|
104
107
|
console.log(' Created src/assets/tailwind/input.css');
|
|
105
108
|
}
|
|
106
109
|
|
|
@@ -209,11 +212,13 @@ url: post.title
|
|
|
209
212
|
const pkgPath = path.join(cwd, 'package.json');
|
|
210
213
|
let pkg = {
|
|
211
214
|
name: projectName,
|
|
215
|
+
description: projectDescription,
|
|
212
216
|
version: "1.0.0",
|
|
213
217
|
type: "module",
|
|
214
218
|
scripts: {
|
|
215
219
|
build: "bricklayer",
|
|
216
|
-
"build:prod": "bricklayer --prod"
|
|
220
|
+
"build:prod": "bricklayer --prod",
|
|
221
|
+
manage: "bricklayer manage"
|
|
217
222
|
},
|
|
218
223
|
dependencies: {},
|
|
219
224
|
devDependencies: {
|
|
@@ -229,16 +234,17 @@ url: post.title
|
|
|
229
234
|
try {
|
|
230
235
|
const existing = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
|
|
231
236
|
pkg = { ...pkg, ...existing };
|
|
232
|
-
pkg.
|
|
233
|
-
pkg.
|
|
237
|
+
pkg.scripts = { ...(existing.scripts || {}), ...pkg.scripts };
|
|
238
|
+
pkg.dependencies = { ...(existing.dependencies || {}), ...pkg.dependencies };
|
|
239
|
+
pkg.devDependencies = { ...(existing.devDependencies || {}), ...pkg.devDependencies };
|
|
234
240
|
} catch(e) {}
|
|
235
241
|
}
|
|
236
242
|
|
|
237
243
|
if (includeCloudflare) {
|
|
238
244
|
console.log('\nConfiguring Cloudflare...');
|
|
239
245
|
pkg.scripts.start = "wrangler dev";
|
|
240
|
-
pkg.scripts["deploy:preview"] = "npm run build:prod &&
|
|
241
|
-
pkg.scripts["deploy:prod"] = "npm run build:prod &&
|
|
246
|
+
pkg.scripts["deploy:preview"] = "npm run build:prod && bricklayer deploy --preview";
|
|
247
|
+
pkg.scripts["deploy:prod"] = "npm run build:prod && bricklayer deploy";
|
|
242
248
|
pkg.devDependencies.wrangler = "^3.0.0";
|
|
243
249
|
|
|
244
250
|
const wranglerPath = path.join(cwd, 'wrangler.toml');
|
|
@@ -365,8 +371,9 @@ ${deploySteps}`;
|
|
|
365
371
|
|
|
366
372
|
const basebrickConfigPath = path.join(cwd, '.basebrick.config');
|
|
367
373
|
const basebrickConfig = {
|
|
368
|
-
projectName,
|
|
369
|
-
|
|
374
|
+
name: projectName,
|
|
375
|
+
description: projectDescription,
|
|
376
|
+
githubUrl: githubRepo,
|
|
370
377
|
includeDemo,
|
|
371
378
|
includeSonic,
|
|
372
379
|
pullSonic,
|
package/bin/manage.js
CHANGED
|
@@ -10,7 +10,8 @@ const rl = readline.createInterface({
|
|
|
10
10
|
|
|
11
11
|
const question = (query) => new Promise((resolve) => rl.question(query, resolve));
|
|
12
12
|
|
|
13
|
-
export async function manageProject(cwd) {
|
|
13
|
+
export async function manageProject(cwd, options = {}) {
|
|
14
|
+
const { reconfigure = false, url = null, token = null } = options;
|
|
14
15
|
const localConfigPath = path.join(cwd, '.basebrick.config');
|
|
15
16
|
|
|
16
17
|
if (!fs.existsSync(localConfigPath)) {
|
|
@@ -30,9 +31,28 @@ export async function manageProject(cwd) {
|
|
|
30
31
|
}
|
|
31
32
|
}
|
|
32
33
|
|
|
34
|
+
if (reconfigure) {
|
|
35
|
+
managerConfig = {};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (url) {
|
|
39
|
+
managerConfig.url = url;
|
|
40
|
+
}
|
|
41
|
+
if (token) {
|
|
42
|
+
managerConfig.token = token;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (managerConfig.url && !managerConfig.url.endsWith('/api/sites')) {
|
|
46
|
+
managerConfig.url = managerConfig.url.replace(/\/+$/, '') + '/api/sites';
|
|
47
|
+
}
|
|
48
|
+
|
|
33
49
|
if (!managerConfig.url) {
|
|
34
50
|
const urlRaw = await question('What is your Bricklayer Manager URL? ');
|
|
35
|
-
|
|
51
|
+
let url = urlRaw.trim();
|
|
52
|
+
if (!url.endsWith('/api/sites')) {
|
|
53
|
+
url = url.replace(/\/+$/, '') + '/api/sites';
|
|
54
|
+
}
|
|
55
|
+
managerConfig.url = url;
|
|
36
56
|
}
|
|
37
57
|
|
|
38
58
|
if (!managerConfig.token) {
|
|
@@ -58,6 +78,81 @@ export async function manageProject(cwd) {
|
|
|
58
78
|
return;
|
|
59
79
|
}
|
|
60
80
|
|
|
81
|
+
// Consolidate legacy keys to prefer user edits
|
|
82
|
+
if (projectConfig.projectName && projectConfig.projectName !== projectConfig.name) {
|
|
83
|
+
projectConfig.name = projectConfig.projectName;
|
|
84
|
+
}
|
|
85
|
+
if (projectConfig.projectDescription && projectConfig.projectDescription !== projectConfig.description) {
|
|
86
|
+
projectConfig.description = projectConfig.projectDescription;
|
|
87
|
+
}
|
|
88
|
+
if (projectConfig.githubRepo && projectConfig.githubRepo !== projectConfig.githubUrl) {
|
|
89
|
+
projectConfig.githubUrl = projectConfig.githubRepo;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Clean up duplicate legacy keys
|
|
93
|
+
delete projectConfig.projectName;
|
|
94
|
+
delete projectConfig.projectDescription;
|
|
95
|
+
delete projectConfig.githubRepo;
|
|
96
|
+
|
|
97
|
+
// Normalize githubUrl
|
|
98
|
+
if (projectConfig.githubUrl) {
|
|
99
|
+
projectConfig.githubUrl = projectConfig.githubUrl.replace(/^git\+/, '').replace(/\.git$/, '');
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Extract description from package.json if not explicitly set
|
|
103
|
+
const pkgPath = path.join(cwd, 'package.json');
|
|
104
|
+
if (fs.existsSync(pkgPath)) {
|
|
105
|
+
try {
|
|
106
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
|
|
107
|
+
if (pkg.description && !projectConfig.description) {
|
|
108
|
+
projectConfig.description = pkg.description;
|
|
109
|
+
}
|
|
110
|
+
if (pkg.repository && pkg.repository.url && !projectConfig.githubUrl) {
|
|
111
|
+
let repoUrl = pkg.repository.url.replace(/^git\+/, '').replace(/\.git$/, '');
|
|
112
|
+
projectConfig.githubUrl = repoUrl;
|
|
113
|
+
}
|
|
114
|
+
} catch (e) {
|
|
115
|
+
console.error('Warning: Could not parse package.json');
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Extract name and account_id from wrangler.toml
|
|
120
|
+
const wranglerPath = path.join(cwd, 'wrangler.toml');
|
|
121
|
+
if (fs.existsSync(wranglerPath)) {
|
|
122
|
+
try {
|
|
123
|
+
const wranglerContent = fs.readFileSync(wranglerPath, 'utf8');
|
|
124
|
+
const nameMatch = wranglerContent.match(/^name\s*=\s*"([^"]+)"/m);
|
|
125
|
+
if (nameMatch && nameMatch[1] && !projectConfig.name) {
|
|
126
|
+
projectConfig.name = nameMatch[1];
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const accountMatch = wranglerContent.match(/^account_id\s*=\s*"([^"]+)"/m);
|
|
130
|
+
if (accountMatch && accountMatch[1]) {
|
|
131
|
+
projectConfig.accountId = accountMatch[1];
|
|
132
|
+
} else if (process.env.CLOUDFLARE_ACCOUNT_ID) {
|
|
133
|
+
projectConfig.accountId = process.env.CLOUDFLARE_ACCOUNT_ID;
|
|
134
|
+
}
|
|
135
|
+
} catch (e) {
|
|
136
|
+
console.error('Warning: Could not parse wrangler.toml');
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (!projectConfig.accountId) {
|
|
141
|
+
try {
|
|
142
|
+
const { execSync } = await import('child_process');
|
|
143
|
+
const whoamiOutput = execSync('npx wrangler whoami', { stdio: 'pipe', encoding: 'utf8' });
|
|
144
|
+
const match = whoamiOutput.match(/Account ID[^\n]*?([a-f0-9]{32})/i);
|
|
145
|
+
if (match && match[1]) {
|
|
146
|
+
projectConfig.accountId = match[1];
|
|
147
|
+
}
|
|
148
|
+
} catch (e) {
|
|
149
|
+
// Ignore if whoami fails
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Save cleaned config back to .basebrick.config
|
|
154
|
+
fs.writeFileSync(localConfigPath, JSON.stringify(projectConfig, null, 2));
|
|
155
|
+
|
|
61
156
|
console.log(`\nSending project configuration to ${managerConfig.url}...`);
|
|
62
157
|
|
|
63
158
|
let endpoint = managerConfig.url;
|
package/history.md
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
# Version History
|
|
2
2
|
|
|
3
|
+
## 1.2.0 (May 2026)
|
|
4
|
+
|
|
5
|
+
- **CLI Deployment Wrapper**: Introduced `bricklayer deploy` command using a pseudo-TTY to seamlessly wrap `wrangler deploy` across environments, automatically intercepting and capturing live Cloudflare URLs.
|
|
6
|
+
- **Automated Config Sync**: The `bricklayer manage` tool now seamlessly parses Cloudflare `account_id` from `wrangler.toml` and syncs it with your central configuration.
|
|
7
|
+
- **Manager Dashboard Upgrades**: Added real-time, silent dashboard polling and integrated direct Cloudflare Worker dashboard deep links for registered sites.
|
|
8
|
+
- **Site Management**: Implemented site deletion functionality within the manager UI and securely moved the API Configuration Token into an isolated Settings page.
|
|
9
|
+
|
|
3
10
|
## 1.1.0 (May 2026)
|
|
4
11
|
|
|
5
12
|
- **Interactive Scaffolding**: Added `bricklayer init` command to scaffold new projects via an interactive wizard, generating `.gitignore` and `.basebrick.config` automatically.
|
package/package.json
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "basebrick-bricklayer",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.2",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "BaseBrick static site generator (JamBrick)",
|
|
6
6
|
"main": "index.js",
|
|
7
7
|
"bin": {
|
|
8
|
-
"bricklayer": "
|
|
8
|
+
"bricklayer": "bin/bricklayer.js"
|
|
9
9
|
},
|
|
10
10
|
"keywords": [
|
|
11
11
|
"jamstack",
|
|
@@ -21,4 +21,4 @@
|
|
|
21
21
|
"gray-matter": "^4.0.3",
|
|
22
22
|
"nunjucks": "^3.2.4"
|
|
23
23
|
}
|
|
24
|
-
}
|
|
24
|
+
}
|