@scribe-platform/cli 0.4.0 → 0.5.0
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 +15 -0
- package/dist/commands/update.d.ts +3 -0
- package/dist/commands/update.d.ts.map +1 -0
- package/dist/commands/update.js +93 -0
- package/dist/commands/update.js.map +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/lib/skill.d.ts +45 -0
- package/dist/lib/skill.d.ts.map +1 -0
- package/dist/lib/skill.js +70 -0
- package/dist/lib/skill.js.map +1 -0
- package/package.json +3 -1
- package/skills/scribe-site-builder/SKILL.md +373 -0
- package/skills/scribe-site-builder/recipes/README.md +31 -0
- package/skills/scribe-site-builder/recipes/collections.md +87 -0
- package/skills/scribe-site-builder/recipes/deploy.md +122 -0
- package/skills/scribe-site-builder/recipes/forms.md +231 -0
- package/skills/scribe-site-builder/recipes/schema.md +99 -0
package/README.md
CHANGED
|
@@ -32,6 +32,21 @@ It defaults to the hosted admin at `https://scribe.weaverdigital.io` — pass
|
|
|
32
32
|
scribe login --token <key> --api-url http://localhost:3000
|
|
33
33
|
```
|
|
34
34
|
|
|
35
|
+
## Update
|
|
36
|
+
|
|
37
|
+
```
|
|
38
|
+
scribe update
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Installs the latest `@scribe-platform/cli` and copies the bundled
|
|
42
|
+
`scribe-site-builder` Claude Code skill into `~/.claude/skills/`, so the CLI and
|
|
43
|
+
the instructions Claude reads move together. Restart Claude Code afterwards — it
|
|
44
|
+
only discovers skills at session start.
|
|
45
|
+
|
|
46
|
+
Requires a global npm install; it refuses to guess for a checkout, `npx`, or
|
|
47
|
+
another package manager. A symlinked `~/.claude/skills/scribe-site-builder` is
|
|
48
|
+
left untouched, so a dev checkout's live skill isn't replaced by a copy.
|
|
49
|
+
|
|
35
50
|
## Commands
|
|
36
51
|
|
|
37
52
|
Run `scribe <group> --help` for the full options on any of these.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"update.d.ts","sourceRoot":"","sources":["../../src/commands/update.ts"],"names":[],"mappings":"AAIA,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAgDpC,wBAAgB,qBAAqB,CAAC,OAAO,EAAE,OAAO,GAAG,IAAI,CAoD5D"}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { readFileSync, realpathSync } from 'node:fs';
|
|
3
|
+
import { dirname, join, resolve, sep } from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { CliExit, runAction } from '../lib/cli-utils.js';
|
|
6
|
+
import { installSkill, tildify } from '../lib/skill.js';
|
|
7
|
+
const PACKAGE_NAME = '@scribe-platform/cli';
|
|
8
|
+
/** This install's own package root — `<global npm root>/@scribe-platform/cli` when installed globally. */
|
|
9
|
+
function packageRoot() {
|
|
10
|
+
return resolve(dirname(fileURLToPath(import.meta.url)), '..', '..');
|
|
11
|
+
}
|
|
12
|
+
/** The version recorded on disk right now, which is the new one once `npm install -g` has run. */
|
|
13
|
+
function installedVersion(root) {
|
|
14
|
+
try {
|
|
15
|
+
const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf-8'));
|
|
16
|
+
return pkg.version ?? null;
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
function npm(args, inherit) {
|
|
23
|
+
return spawnSync('npm', args, {
|
|
24
|
+
stdio: inherit ? 'inherit' : 'pipe',
|
|
25
|
+
encoding: 'utf-8',
|
|
26
|
+
// npm is a shell script/.cmd on Windows, so it isn't directly executable there.
|
|
27
|
+
shell: process.platform === 'win32',
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
/** True when `root` sits inside npm's global package directory — i.e. `npm install -g` owns this install. */
|
|
31
|
+
function isGlobalInstall(root) {
|
|
32
|
+
const result = npm(['root', '-g'], false);
|
|
33
|
+
if (result.status !== 0 || typeof result.stdout !== 'string')
|
|
34
|
+
return false;
|
|
35
|
+
// Both sides get realpath'd: nvm, Homebrew and Volta all hand out globals
|
|
36
|
+
// through symlinked prefixes, so a raw string compare gives false negatives.
|
|
37
|
+
const real = (path) => {
|
|
38
|
+
try {
|
|
39
|
+
return realpathSync(path);
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return path;
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
const globalRoot = real(result.stdout.trim());
|
|
46
|
+
return real(root).startsWith(globalRoot + sep);
|
|
47
|
+
}
|
|
48
|
+
export function registerUpdateCommand(program) {
|
|
49
|
+
program
|
|
50
|
+
.command('update')
|
|
51
|
+
// Aliased because `scribe site update` already exists: someone reaching for
|
|
52
|
+
// "upgrade the tool" shouldn't hit a dead end over which word we chose.
|
|
53
|
+
.alias('upgrade')
|
|
54
|
+
.description('Update the scribe CLI and the Claude Code skill that ships with it')
|
|
55
|
+
.action(runAction(async () => {
|
|
56
|
+
const root = packageRoot();
|
|
57
|
+
if (!isGlobalInstall(root)) {
|
|
58
|
+
console.error(`This scribe isn't a global npm install — it's running from ${root}.`);
|
|
59
|
+
console.error('Update it the way it was installed. For a normal install that is:');
|
|
60
|
+
console.error(` npm install -g ${PACKAGE_NAME}@latest`);
|
|
61
|
+
process.exitCode = 1;
|
|
62
|
+
throw new CliExit();
|
|
63
|
+
}
|
|
64
|
+
const before = installedVersion(root);
|
|
65
|
+
console.log(`Updating ${PACKAGE_NAME}${before ? ` (currently ${before})` : ''}...`);
|
|
66
|
+
const install = npm(['install', '-g', `${PACKAGE_NAME}@latest`], true);
|
|
67
|
+
if (install.status !== 0) {
|
|
68
|
+
console.error(`\nnpm install -g ${PACKAGE_NAME}@latest failed.`);
|
|
69
|
+
console.error("If that was a permission error (EACCES), fix npm's global prefix or re-run it with sudo.");
|
|
70
|
+
process.exitCode = 1;
|
|
71
|
+
throw new CliExit();
|
|
72
|
+
}
|
|
73
|
+
// npm has replaced this package's files in place. The code already
|
|
74
|
+
// running is the old version, but everything below reads from disk, so
|
|
75
|
+
// the skill installed is the one that ships with the new version.
|
|
76
|
+
const after = installedVersion(root);
|
|
77
|
+
console.log(after && after !== before ? `\nUpdated to ${after}.` : `\nAlready on the latest version${after ? ` (${after})` : ''}.`);
|
|
78
|
+
const skill = installSkill();
|
|
79
|
+
if (skill.status === 'missing-source') {
|
|
80
|
+
console.error("The CLI updated, but its bundled skill wasn't where this version expected it.");
|
|
81
|
+
console.error('Run `scribe update` once more — the newer code knows where to look.');
|
|
82
|
+
process.exitCode = 1;
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
if (skill.status === 'symlink') {
|
|
86
|
+
console.log(`Skill: left ${tildify(skill.dest)} alone — it's a symlink into a checkout.`);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
console.log(`Skill: installed scribe-site-builder to ${tildify(skill.dest)}.`);
|
|
90
|
+
console.log('\nRestart Claude Code so it picks up the updated skill.');
|
|
91
|
+
}));
|
|
92
|
+
}
|
|
93
|
+
//# sourceMappingURL=update.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"update.js","sourceRoot":"","sources":["../../src/commands/update.ts"],"names":[],"mappings":"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"}
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAgBpC,wBAAgB,aAAa,IAAI,OAAO,CAgBvC;AAED,OAAO,EAAE,YAAY,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC;AAC/D,OAAO,EAAE,UAAU,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAC"}
|
package/dist/index.js
CHANGED
|
@@ -6,6 +6,7 @@ import { registerContentCommands } from './commands/content.js';
|
|
|
6
6
|
import { registerSubmissionCommands } from './commands/submission.js';
|
|
7
7
|
import { registerSchemaCommands } from './commands/schema.js';
|
|
8
8
|
import { registerBuildCommands } from './commands/build.js';
|
|
9
|
+
import { registerUpdateCommand } from './commands/update.js';
|
|
9
10
|
// Read from package.json at runtime rather than hardcoding: `../package.json`
|
|
10
11
|
// resolves to the package root from both `dist/index.js` (published) and
|
|
11
12
|
// `src/index.ts` (tests), so `scribe --version` can't drift from the release.
|
|
@@ -22,6 +23,7 @@ export function createProgram() {
|
|
|
22
23
|
registerSubmissionCommands(program);
|
|
23
24
|
registerSchemaCommands(program);
|
|
24
25
|
registerBuildCommands(program);
|
|
26
|
+
registerUpdateCommand(program);
|
|
25
27
|
return program;
|
|
26
28
|
}
|
|
27
29
|
export { ScribeClient, ScribeApiError } from './lib/client.js';
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,MAAM,SAAS,CAAC;AACvC,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AACpC,OAAO,EAAE,oBAAoB,EAAE,MAAM,qBAAqB,CAAC;AAC3D,OAAO,EAAE,oBAAoB,EAAE,MAAM,oBAAoB,CAAC;AAC1D,OAAO,EAAE,uBAAuB,EAAE,MAAM,uBAAuB,CAAC;AAChE,OAAO,EAAE,0BAA0B,EAAE,MAAM,0BAA0B,CAAC;AACtE,OAAO,EAAE,sBAAsB,EAAE,MAAM,sBAAsB,CAAC;AAC9D,OAAO,EAAE,qBAAqB,EAAE,MAAM,qBAAqB,CAAC;
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,MAAM,SAAS,CAAC;AACvC,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AACpC,OAAO,EAAE,oBAAoB,EAAE,MAAM,qBAAqB,CAAC;AAC3D,OAAO,EAAE,oBAAoB,EAAE,MAAM,oBAAoB,CAAC;AAC1D,OAAO,EAAE,uBAAuB,EAAE,MAAM,uBAAuB,CAAC;AAChE,OAAO,EAAE,0BAA0B,EAAE,MAAM,0BAA0B,CAAC;AACtE,OAAO,EAAE,sBAAsB,EAAE,MAAM,sBAAsB,CAAC;AAC9D,OAAO,EAAE,qBAAqB,EAAE,MAAM,qBAAqB,CAAC;AAC5D,OAAO,EAAE,qBAAqB,EAAE,MAAM,sBAAsB,CAAC;AAE7D,8EAA8E;AAC9E,yEAAyE;AACzE,8EAA8E;AAC9E,MAAM,EAAE,OAAO,EAAE,GAAG,IAAI,CAAC,KAAK,CAC5B,YAAY,CAAC,IAAI,GAAG,CAAC,iBAAiB,EAAE,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,EAAE,OAAO,CAAC,CAC5C,CAAC;AAEzB,MAAM,UAAU,aAAa;IAC3B,MAAM,OAAO,GAAG,IAAI,OAAO,EAAE,CAAC;IAC9B,OAAO;SACJ,IAAI,CAAC,QAAQ,CAAC;SACd,WAAW,CAAC,uCAAuC,CAAC;SACpD,OAAO,CAAC,OAAO,CAAC,CAAC;IAEpB,oBAAoB,CAAC,OAAO,CAAC,CAAC;IAC9B,oBAAoB,CAAC,OAAO,CAAC,CAAC;IAC9B,uBAAuB,CAAC,OAAO,CAAC,CAAC;IACjC,0BAA0B,CAAC,OAAO,CAAC,CAAC;IACpC,sBAAsB,CAAC,OAAO,CAAC,CAAC;IAChC,qBAAqB,CAAC,OAAO,CAAC,CAAC;IAC/B,qBAAqB,CAAC,OAAO,CAAC,CAAC;IAE/B,OAAO,OAAO,CAAC;AACjB,CAAC;AAED,OAAO,EAAE,YAAY,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC;AAC/D,OAAO,EAAE,UAAU,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAC"}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/** The Claude Code skill this package ships, as both its directory and skill name. */
|
|
2
|
+
export declare const SKILL_NAME = "scribe-site-builder";
|
|
3
|
+
/** Where Claude Code discovers globally-installed skills. */
|
|
4
|
+
export declare function skillDestination(): string;
|
|
5
|
+
/**
|
|
6
|
+
* The skill bundled with this install, or null if it can't be found. Two
|
|
7
|
+
* candidates, tried in order:
|
|
8
|
+
*
|
|
9
|
+
* 1. `<package>/skills/<name>` — a published tarball, where `prepack`
|
|
10
|
+
* (scripts/bundle-skill.mjs) copied the repo's skill in.
|
|
11
|
+
* 2. `<repo>/skills/<name>` — a dev checkout, where the skill only exists at
|
|
12
|
+
* the monorepo root.
|
|
13
|
+
*
|
|
14
|
+
* Both are the same number of levels up from this file whether it runs as
|
|
15
|
+
* `src/lib/skill.ts` (tests) or `dist/lib/skill.js` (published).
|
|
16
|
+
*
|
|
17
|
+
* Resolved at call time, on purpose: `scribe update` calls this *after* `npm
|
|
18
|
+
* install -g` has replaced this package's files, so it reads the skill that
|
|
19
|
+
* ships with the version just installed rather than the one the running process
|
|
20
|
+
* started with.
|
|
21
|
+
*/
|
|
22
|
+
export declare function bundledSkillPath(): string | null;
|
|
23
|
+
export type SkillInstallResult = {
|
|
24
|
+
status: 'installed';
|
|
25
|
+
dest: string;
|
|
26
|
+
} | {
|
|
27
|
+
status: 'symlink';
|
|
28
|
+
dest: string;
|
|
29
|
+
} | {
|
|
30
|
+
status: 'missing-source';
|
|
31
|
+
};
|
|
32
|
+
/**
|
|
33
|
+
* Copies the bundled skill into ~/.claude/skills, replacing whatever is there.
|
|
34
|
+
*
|
|
35
|
+
* A **symlink** at the destination is left untouched: that's a dev checkout
|
|
36
|
+
* pointing at the repo's own skills/ directory, and overwriting it with a copy
|
|
37
|
+
* would silently detach the author's live editing target.
|
|
38
|
+
*/
|
|
39
|
+
export declare function installSkill(options?: {
|
|
40
|
+
source?: string | null;
|
|
41
|
+
dest?: string;
|
|
42
|
+
}): SkillInstallResult;
|
|
43
|
+
/** Shortens a path for display, so output reads `~/.claude/skills/...`. */
|
|
44
|
+
export declare function tildify(path: string): string;
|
|
45
|
+
//# sourceMappingURL=skill.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"skill.d.ts","sourceRoot":"","sources":["../../src/lib/skill.ts"],"names":[],"mappings":"AAKA,sFAAsF;AACtF,eAAO,MAAM,UAAU,wBAAwB,CAAC;AAEhD,6DAA6D;AAC7D,wBAAgB,gBAAgB,IAAI,MAAM,CAEzC;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,gBAAgB,IAAI,MAAM,GAAG,IAAI,CAOhD;AAED,MAAM,MAAM,kBAAkB,GAC1B;IAAE,MAAM,EAAE,WAAW,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,GACrC;IAAE,MAAM,EAAE,SAAS,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,GACnC;IAAE,MAAM,EAAE,gBAAgB,CAAA;CAAE,CAAC;AAWjC;;;;;;GAMG;AACH,wBAAgB,YAAY,CAAC,OAAO,GAAE;IAAE,MAAM,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAAC,IAAI,CAAC,EAAE,MAAM,CAAA;CAAO,GAAG,kBAAkB,CAaxG;AAED,2EAA2E;AAC3E,wBAAgB,OAAO,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAG5C"}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { cpSync, existsSync, lstatSync, mkdirSync, rmSync } from 'node:fs';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
/** The Claude Code skill this package ships, as both its directory and skill name. */
|
|
6
|
+
export const SKILL_NAME = 'scribe-site-builder';
|
|
7
|
+
/** Where Claude Code discovers globally-installed skills. */
|
|
8
|
+
export function skillDestination() {
|
|
9
|
+
return join(homedir(), '.claude', 'skills', SKILL_NAME);
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* The skill bundled with this install, or null if it can't be found. Two
|
|
13
|
+
* candidates, tried in order:
|
|
14
|
+
*
|
|
15
|
+
* 1. `<package>/skills/<name>` — a published tarball, where `prepack`
|
|
16
|
+
* (scripts/bundle-skill.mjs) copied the repo's skill in.
|
|
17
|
+
* 2. `<repo>/skills/<name>` — a dev checkout, where the skill only exists at
|
|
18
|
+
* the monorepo root.
|
|
19
|
+
*
|
|
20
|
+
* Both are the same number of levels up from this file whether it runs as
|
|
21
|
+
* `src/lib/skill.ts` (tests) or `dist/lib/skill.js` (published).
|
|
22
|
+
*
|
|
23
|
+
* Resolved at call time, on purpose: `scribe update` calls this *after* `npm
|
|
24
|
+
* install -g` has replaced this package's files, so it reads the skill that
|
|
25
|
+
* ships with the version just installed rather than the one the running process
|
|
26
|
+
* started with.
|
|
27
|
+
*/
|
|
28
|
+
export function bundledSkillPath() {
|
|
29
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
30
|
+
const candidates = [
|
|
31
|
+
join(here, '..', '..', 'skills', SKILL_NAME),
|
|
32
|
+
join(here, '..', '..', '..', '..', 'skills', SKILL_NAME),
|
|
33
|
+
];
|
|
34
|
+
return candidates.find((candidate) => existsSync(join(candidate, 'SKILL.md'))) ?? null;
|
|
35
|
+
}
|
|
36
|
+
/** lstat without throwing, so a missing path and a broken symlink are both answerable. */
|
|
37
|
+
function lstatOrNull(path) {
|
|
38
|
+
try {
|
|
39
|
+
return lstatSync(path);
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Copies the bundled skill into ~/.claude/skills, replacing whatever is there.
|
|
47
|
+
*
|
|
48
|
+
* A **symlink** at the destination is left untouched: that's a dev checkout
|
|
49
|
+
* pointing at the repo's own skills/ directory, and overwriting it with a copy
|
|
50
|
+
* would silently detach the author's live editing target.
|
|
51
|
+
*/
|
|
52
|
+
export function installSkill(options = {}) {
|
|
53
|
+
const source = options.source === undefined ? bundledSkillPath() : options.source;
|
|
54
|
+
const dest = options.dest ?? skillDestination();
|
|
55
|
+
if (!source)
|
|
56
|
+
return { status: 'missing-source' };
|
|
57
|
+
const existing = lstatOrNull(dest);
|
|
58
|
+
if (existing?.isSymbolicLink())
|
|
59
|
+
return { status: 'symlink', dest };
|
|
60
|
+
mkdirSync(dirname(dest), { recursive: true });
|
|
61
|
+
rmSync(dest, { recursive: true, force: true });
|
|
62
|
+
cpSync(source, dest, { recursive: true });
|
|
63
|
+
return { status: 'installed', dest };
|
|
64
|
+
}
|
|
65
|
+
/** Shortens a path for display, so output reads `~/.claude/skills/...`. */
|
|
66
|
+
export function tildify(path) {
|
|
67
|
+
const home = homedir();
|
|
68
|
+
return path.startsWith(home) ? `~${path.slice(home.length)}` : path;
|
|
69
|
+
}
|
|
70
|
+
//# sourceMappingURL=skill.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"skill.js","sourceRoot":"","sources":["../../src/lib/skill.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,UAAU,EAAE,SAAS,EAAE,SAAS,EAAE,MAAM,EAAc,MAAM,SAAS,CAAC;AACvF,OAAO,EAAE,OAAO,EAAE,MAAM,SAAS,CAAC;AAClC,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAC;AAC1C,OAAO,EAAE,aAAa,EAAE,MAAM,UAAU,CAAC;AAEzC,sFAAsF;AACtF,MAAM,CAAC,MAAM,UAAU,GAAG,qBAAqB,CAAC;AAEhD,6DAA6D;AAC7D,MAAM,UAAU,gBAAgB;IAC9B,OAAO,IAAI,CAAC,OAAO,EAAE,EAAE,SAAS,EAAE,QAAQ,EAAE,UAAU,CAAC,CAAC;AAC1D,CAAC;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,MAAM,UAAU,gBAAgB;IAC9B,MAAM,IAAI,GAAG,OAAO,CAAC,aAAa,CAAC,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC;IACrD,MAAM,UAAU,GAAG;QACjB,IAAI,CAAC,IAAI,EAAE,IAAI,EAAE,IAAI,EAAE,QAAQ,EAAE,UAAU,CAAC;QAC5C,IAAI,CAAC,IAAI,EAAE,IAAI,EAAE,IAAI,EAAE,IAAI,EAAE,IAAI,EAAE,QAAQ,EAAE,UAAU,CAAC;KACzD,CAAC;IACF,OAAO,UAAU,CAAC,IAAI,CAAC,CAAC,SAAS,EAAE,EAAE,CAAC,UAAU,CAAC,IAAI,CAAC,SAAS,EAAE,UAAU,CAAC,CAAC,CAAC,IAAI,IAAI,CAAC;AACzF,CAAC;AAOD,0FAA0F;AAC1F,SAAS,WAAW,CAAC,IAAY;IAC/B,IAAI,CAAC;QACH,OAAO,SAAS,CAAC,IAAI,CAAC,CAAC;IACzB,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,IAAI,CAAC;IACd,CAAC;AACH,CAAC;AAED;;;;;;GAMG;AACH,MAAM,UAAU,YAAY,CAAC,UAAqD,EAAE;IAClF,MAAM,MAAM,GAAG,OAAO,CAAC,MAAM,KAAK,SAAS,CAAC,CAAC,CAAC,gBAAgB,EAAE,CAAC,CAAC,CAAC,OAAO,CAAC,MAAM,CAAC;IAClF,MAAM,IAAI,GAAG,OAAO,CAAC,IAAI,IAAI,gBAAgB,EAAE,CAAC;IAEhD,IAAI,CAAC,MAAM;QAAE,OAAO,EAAE,MAAM,EAAE,gBAAgB,EAAE,CAAC;IAEjD,MAAM,QAAQ,GAAG,WAAW,CAAC,IAAI,CAAC,CAAC;IACnC,IAAI,QAAQ,EAAE,cAAc,EAAE;QAAE,OAAO,EAAE,MAAM,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC;IAEnE,SAAS,CAAC,OAAO,CAAC,IAAI,CAAC,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC;IAC9C,MAAM,CAAC,IAAI,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,KAAK,EAAE,IAAI,EAAE,CAAC,CAAC;IAC/C,MAAM,CAAC,MAAM,EAAE,IAAI,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC;IAC1C,OAAO,EAAE,MAAM,EAAE,WAAW,EAAE,IAAI,EAAE,CAAC;AACvC,CAAC;AAED,2EAA2E;AAC3E,MAAM,UAAU,OAAO,CAAC,IAAY;IAClC,MAAM,IAAI,GAAG,OAAO,EAAE,CAAC;IACvB,OAAO,IAAI,CAAC,UAAU,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,IAAI,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,MAAM,CAAC,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC;AACtE,CAAC"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@scribe-platform/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Command-line interface for Scribe CMS — scaffold sites, edit content, and triage form submissions from the terminal",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Dave Weaver",
|
|
@@ -32,6 +32,7 @@
|
|
|
32
32
|
"files": [
|
|
33
33
|
"bin",
|
|
34
34
|
"dist",
|
|
35
|
+
"skills",
|
|
35
36
|
"README.md",
|
|
36
37
|
"LICENSE"
|
|
37
38
|
],
|
|
@@ -43,6 +44,7 @@
|
|
|
43
44
|
},
|
|
44
45
|
"scripts": {
|
|
45
46
|
"build": "tsc -p tsconfig.json",
|
|
47
|
+
"prepack": "node scripts/bundle-skill.mjs",
|
|
46
48
|
"test": "NODE_OPTIONS=--experimental-vm-modules jest",
|
|
47
49
|
"lint": "tsc -p tsconfig.json --noEmit",
|
|
48
50
|
"prepublishOnly": "npm run build"
|
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: scribe-site-builder
|
|
3
|
+
description: Guides building a website on Scribe CMS end to end — designing the Astro site freely against Scribe as its backend, editing the `.scribe.yml` schema, adding one or many forms, the local dev loop, pushing content, and deploying — using the `scribe` CLI and the recipes in `recipes/`. Also the reference for working an existing Scribe site (adding fields/collections, publishing content, triaging form submissions, deleting a site). Use whenever the user wants to build/create/start a new website or site with Scribe, add a form to a site, work on an existing Scribe site, or asks about the `scribe` CLI.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Building a site with Scribe
|
|
7
|
+
|
|
8
|
+
Source of truth: this file is the merged, agent-facing version of
|
|
9
|
+
`docs/BUILDER-GUIDE.md` and `docs/AGENT-GUIDE.md` in the
|
|
10
|
+
`ai-xerro-agent/projects/scribe` repo. If those docs and this one ever
|
|
11
|
+
disagree, the repo docs win — this file may be behind.
|
|
12
|
+
|
|
13
|
+
## Recipes — read the one you need
|
|
14
|
+
|
|
15
|
+
Per RFC 0010 the Astro site is **yours to design**. Scribe doesn't supply a
|
|
16
|
+
template you must conform to; it supplies patterns. Load the relevant file
|
|
17
|
+
instead of reading all of them:
|
|
18
|
+
|
|
19
|
+
| Recipe | Use it when |
|
|
20
|
+
|---|---|
|
|
21
|
+
| [`recipes/schema.md`](./recipes/schema.md) | you need the Site's collections and field types |
|
|
22
|
+
| [`recipes/collections.md`](./recipes/collections.md) | rendering editor-managed content |
|
|
23
|
+
| [`recipes/forms.md`](./recipes/forms.md) | adding one or more forms |
|
|
24
|
+
| [`recipes/deploy.md`](./recipes/deploy.md) | build/deploy setup or debugging |
|
|
25
|
+
|
|
26
|
+
[`recipes/README.md`](./recipes/README.md) lists the five things that are a hard
|
|
27
|
+
contract (because Scribe writes into the repo) as opposed to advice.
|
|
28
|
+
|
|
29
|
+
## Mental model
|
|
30
|
+
|
|
31
|
+
- **A site = a GitHub repo.** Schema, content, and the Astro source all live in one
|
|
32
|
+
repo. The admin app and the `scribe` CLI are both just clients that read/write
|
|
33
|
+
files in it via the GitHub API; a local `git clone` is a third client reading/writing
|
|
34
|
+
the same files via git.
|
|
35
|
+
- **Form submissions are the exception — they live in MongoDB, not the repo** (RFC
|
|
36
|
+
0004). A Visitor's name, email and message used to be committed as a YAML file into
|
|
37
|
+
the site's public repo, which published them to the open internet. Nothing writes
|
|
38
|
+
submissions to a repo any more: a submission is a Mongo document with a status and
|
|
39
|
+
an append-only event history, and `content/<form-collection>/` doesn't exist.
|
|
40
|
+
- **`.scribe.yml`** at the repo root is the schema — every collection (content type)
|
|
41
|
+
and its fields, plus which collections are forms. Prefer the CLI over hand-editing,
|
|
42
|
+
because it validates before committing and because the admin treats the committed
|
|
43
|
+
file as authoritative: `scribe schema show <site>` to read,
|
|
44
|
+
`scribe schema apply <site> --file <spec>` to create or replace a whole collection,
|
|
45
|
+
`scribe schema add`/`remove` for a bare stub or a removal. Hand-editing still works
|
|
46
|
+
for a one-line tweak in a clone.
|
|
47
|
+
- **A Site can have many forms.** Each `form: true` collection gets its own inbox,
|
|
48
|
+
its own workflow states and its own nav badge in the admin, and posts to
|
|
49
|
+
`/api/submissions?site=…&collection=…`. See `recipes/forms.md`.
|
|
50
|
+
- **No merge tooling.** Admin-UI edits and local edits both land as commits on the
|
|
51
|
+
same repo. If they touch the same file, resolve it with plain `git pull` / merge —
|
|
52
|
+
there's no `scribe` conflict command.
|
|
53
|
+
- **`scripts/generate-config.mjs` is legacy.** Sites scaffolded before RFC 0010 have
|
|
54
|
+
it, and it regenerates `src/content.config.ts` and `src/site-config.ts` on
|
|
55
|
+
`predev`/`prebuild` — so in those repos, don't hand-edit those two files, it will
|
|
56
|
+
overwrite you. It flattens the schema lossily (field *names* with no types, and
|
|
57
|
+
only the **first** `form: true` collection), so a site that needs real types or a
|
|
58
|
+
second form should read `.scribe.yml` directly instead — see `recipes/schema.md`.
|
|
59
|
+
New sites shouldn't add it.
|
|
60
|
+
|
|
61
|
+
## Setup (once)
|
|
62
|
+
|
|
63
|
+
- **CLI**: confirm `scribe --help` works. If it's not found, `npm link` from
|
|
64
|
+
`packages/cli` in the `ai-xerro-agent/projects/scribe` repo.
|
|
65
|
+
- **Login**: `scribe login --token <key>` — mint the key from the
|
|
66
|
+
admin UI (sign in → account menu → **API Tokens** → New token). Defaults to the
|
|
67
|
+
hosted admin at `https://scribe.weaverdigital.io`; add `--api-url <url>` only to
|
|
68
|
+
point at a different instance (a local dev/Docker admin). Writes
|
|
69
|
+
`~/.scribe/config.json`; every other command reads from there, no per-invocation
|
|
70
|
+
`--token` flag. If a command fails with a raw `fetch failed`, that's not an API
|
|
71
|
+
error — it means `apiUrl` in that config doesn't point at anything listening;
|
|
72
|
+
re-run `scribe login` with the right URL.
|
|
73
|
+
|
|
74
|
+
## Keeping Scribe up to date
|
|
75
|
+
|
|
76
|
+
`scribe update` updates the CLI **and** reinstalls this skill, which ships inside
|
|
77
|
+
the `@scribe-platform/cli` npm package:
|
|
78
|
+
|
|
79
|
+
```
|
|
80
|
+
scribe update
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Two things to know:
|
|
84
|
+
|
|
85
|
+
- **Tell the user to restart Claude Code afterwards.** Claude Code only discovers
|
|
86
|
+
skills at session start, so the session that ran the update is still reading the
|
|
87
|
+
old copy of this file. If you need the new instructions in the current session,
|
|
88
|
+
read `~/.claude/skills/scribe-site-builder/SKILL.md` from disk instead of waiting
|
|
89
|
+
for a restart.
|
|
90
|
+
- **It only works for a global npm install** (`npm install -g
|
|
91
|
+
@scribe-platform/cli`). In a dev checkout of the scribe repo it refuses, and if
|
|
92
|
+
`~/.claude/skills/scribe-site-builder` is a symlink into a checkout it leaves it
|
|
93
|
+
alone — that's an author editing the skill in place.
|
|
94
|
+
|
|
95
|
+
Run it when the user says there's a new version, or when a CLI command fails in a
|
|
96
|
+
way that suggests this skill is behind the API (an option the CLI doesn't
|
|
97
|
+
recognise, a command that no longer exists).
|
|
98
|
+
|
|
99
|
+
## Workflow: building a new site
|
|
100
|
+
|
|
101
|
+
1. **Scaffold.**
|
|
102
|
+
```
|
|
103
|
+
scribe site add --slug my-site
|
|
104
|
+
```
|
|
105
|
+
Creates the GitHub repo, pushes the Astro starter template
|
|
106
|
+
(home/blog/contact pages, a default `.scribe.yml` with `posts` and `inquiries`
|
|
107
|
+
collections), and registers the site with the admin — one call.
|
|
108
|
+
`--name` sets a display name (defaults to the slug); `--form-collection` renames
|
|
109
|
+
the default contact-form collection (defaults to `inquiries`).
|
|
110
|
+
|
|
111
|
+
`--domain` and `--turnstile-key` are both optional and normally omitted. The
|
|
112
|
+
domain defaults to the `scribe-platform.github.io` Pages host every new site is
|
|
113
|
+
served from, and the Turnstile site key defaults to the shared widget configured
|
|
114
|
+
on the admin app — one widget covers all sites, scoped by its allowed-domain
|
|
115
|
+
list rather than by key. Pass them only to override those defaults. The real
|
|
116
|
+
customer domain comes later, at step 8.
|
|
117
|
+
|
|
118
|
+
2. **Clone it locally.**
|
|
119
|
+
```
|
|
120
|
+
scribe site clone my-site
|
|
121
|
+
```
|
|
122
|
+
Plain `git clone`. Nothing Scribe-specific happens locally — `npm run dev` works,
|
|
123
|
+
`.scribe.yml` is just a file in the repo root.
|
|
124
|
+
|
|
125
|
+
3. **Define/edit the schema.** Hand-edit `.scribe.yml` — add fields under
|
|
126
|
+
`collections[].fields[]` (see field reference below). Once saved,
|
|
127
|
+
`npm run dev` / `npm run build` locally regenerate the Astro content types
|
|
128
|
+
automatically (via `predev`/`prebuild`) — no separate sync step. A `git push` of
|
|
129
|
+
`.scribe.yml` is enough to reach the admin, which reads it live from GitHub,
|
|
130
|
+
cached **8 minutes** per repo — after a schema push, the admin UI may lag up to
|
|
131
|
+
8 minutes before showing it.
|
|
132
|
+
|
|
133
|
+
4. **Build content locally.** Normal Astro dev loop — content files live under
|
|
134
|
+
`content/<collection>/*` per the collection's `path`/`format`.
|
|
135
|
+
|
|
136
|
+
5. **Push to Scribe.** Two ways:
|
|
137
|
+
- `git push` — commits schema and/or content straight to the repo. Normal path.
|
|
138
|
+
- `scribe content publish <site> <file>` — pushes one local file through the
|
|
139
|
+
API, validating it against the schema server-side first (a raw `git push`
|
|
140
|
+
skips that validation).
|
|
141
|
+
|
|
142
|
+
6. **Admin-UI edits, pulled back down.** The admin UI writes commits to the same
|
|
143
|
+
repo — `git pull` locally to pick them up. Conflicts resolve as ordinary git
|
|
144
|
+
merge conflicts, nothing purpose-built.
|
|
145
|
+
|
|
146
|
+
7. **Deploy.** Any push to the repo's default branch runs `.github/workflows/deploy.yml`
|
|
147
|
+
automatically (build + GitHub Pages). `scribe build trigger my-site` dispatches
|
|
148
|
+
the same workflow on demand without a new commit — debounced to once per 5
|
|
149
|
+
minutes per site, so repeat calls in that window are a silent no-op.
|
|
150
|
+
|
|
151
|
+
8. **Domain cutover — only when a customer's own hostname is ready.** Skip this
|
|
152
|
+
entirely for demo sites; the GitHub Pages default already works.
|
|
153
|
+
|
|
154
|
+
Check what DNS the domain needs first. This changes nothing:
|
|
155
|
+
```
|
|
156
|
+
scribe site promote my-site --domain example.com --dns-only
|
|
157
|
+
```
|
|
158
|
+
It prints the records to add at the registrar. An apex domain needs the four A
|
|
159
|
+
records; a subdomain needs only the CNAME. **In Cloudflare the record must be
|
|
160
|
+
DNS-only (grey cloud), not proxied** — the orange-cloud proxy blocks the
|
|
161
|
+
challenge GitHub uses to issue the site's certificate.
|
|
162
|
+
|
|
163
|
+
Once the records are in place and resolving:
|
|
164
|
+
```
|
|
165
|
+
scribe site promote my-site --domain example.com
|
|
166
|
+
```
|
|
167
|
+
This does the whole cutover in one go: writes `public/CNAME`, registers the
|
|
168
|
+
domain with GitHub Pages, points the contact form at the admin app, updates the
|
|
169
|
+
hostname the public form endpoint accepts submissions from, adds the domain to
|
|
170
|
+
the Turnstile widget, enforces HTTPS, and redeploys.
|
|
171
|
+
|
|
172
|
+
It **refuses to run until DNS actually points at GitHub Pages**, because the
|
|
173
|
+
cutover kills the old project-page URL — if DNS isn't ready the site is
|
|
174
|
+
unreachable at both addresses. Fix the DNS and run it again rather than reaching
|
|
175
|
+
for `--force`.
|
|
176
|
+
|
|
177
|
+
**Re-running is safe and is the fix for anything that goes wrong.** Every step
|
|
178
|
+
checks the current state before writing, so a run that fails halfway is repaired
|
|
179
|
+
by running the same command again. If it reports that GitHub is still issuing
|
|
180
|
+
the certificate, wait a few minutes and re-run to finish — until then `http://`
|
|
181
|
+
won't serve the site.
|
|
182
|
+
|
|
183
|
+
If it prints a `Warning:` about the Turnstile widget, the domain list did **not**
|
|
184
|
+
sync — fix it in the Cloudflare dashboard, or the contact form silently rejects
|
|
185
|
+
real visitors on that domain.
|
|
186
|
+
|
|
187
|
+
`scribe site update my-site --domain example.com` only edits the stored record
|
|
188
|
+
and the Turnstile widget. Use it to correct a domain, never to cut a site over.
|
|
189
|
+
|
|
190
|
+
## Working an existing site
|
|
191
|
+
|
|
192
|
+
```
|
|
193
|
+
scribe content list <site> [--collection <name>] [--json]
|
|
194
|
+
scribe content read <site> <path> [--json]
|
|
195
|
+
scribe content publish <site> <file> # validates against schema
|
|
196
|
+
scribe content delete <site> <path>
|
|
197
|
+
scribe content versions <site> <path> [--json] # commit history
|
|
198
|
+
scribe content revert <site> <path> --sha <sha> # checks out old version as new commit
|
|
199
|
+
|
|
200
|
+
scribe schema show <site> [collection] [--json] # read the schema
|
|
201
|
+
scribe schema apply <site> --file <spec|-> # create/replace a whole collection
|
|
202
|
+
scribe schema add <site> <collection-name> [--path <path>] # bare stub only, no fields
|
|
203
|
+
scribe schema remove <site> <collection-name> # deletes its folder too
|
|
204
|
+
|
|
205
|
+
scribe submission list <site> <collection> [--status <status>] [--page <n>] [--json]
|
|
206
|
+
scribe submission update <site> <collection> <id> [--status <s>] [--note <n>]
|
|
207
|
+
scribe submission export <site> <collection> [--format csv|json] [--status <s>] [--out <file>]
|
|
208
|
+
scribe submission delete <site> <collection> <id> [--yes] # staff only
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
- `scribe schema apply` is the way to add a form (or any collection with fields): it
|
|
212
|
+
takes a YAML/JSON file describing **one whole collection**, upserts it by `name`,
|
|
213
|
+
and validates through the admin's own parser first — so a reserved `status` field,
|
|
214
|
+
an `image` field on a form collection, or a malformed workflow is reported before
|
|
215
|
+
anything is committed. `--file -` reads from a pipe. Full worked example in
|
|
216
|
+
[`recipes/forms.md`](./recipes/forms.md).
|
|
217
|
+
- `scribe schema add` only appends a bare `{name, path}` stub with no fields; it's for
|
|
218
|
+
starting a content collection interactively. There is still no *field*-level
|
|
219
|
+
command — `schema apply` replaces the whole collection, so include every field.
|
|
220
|
+
- `scribe content publish` reads a **local file at the given repo-relative path**,
|
|
221
|
+
parsing `---`-delimited YAML/JSON frontmatter (or a bare YAML/JSON object with no
|
|
222
|
+
body).
|
|
223
|
+
- `scribe submission list` paginates (1-based `--page`); `--json` gives the whole page
|
|
224
|
+
object, so `total` tells you whether there's a page 2.
|
|
225
|
+
- **`<id>` is a Mongo ObjectId**, as printed by `submission list` — not a filename and
|
|
226
|
+
not a repo path. There's no path inference any more; submissions aren't files.
|
|
227
|
+
- `--note` **appends** to the submission's history. It never overwrites an earlier
|
|
228
|
+
note, and it leaves the submission's own `updatedAt` alone — a note is something
|
|
229
|
+
someone wrote *about* the lead, not a change to it.
|
|
230
|
+
- `submission export` writes to stdout unless `--out` is given, so it pipes
|
|
231
|
+
(`scribe submission export my-site inquiries > leads.csv`). CSV is for a
|
|
232
|
+
spreadsheet and is lossy on purpose: its columns come from the collection's schema,
|
|
233
|
+
so the header row is identical between exports even when a batch left a field blank,
|
|
234
|
+
and it carries no history and no visitor IP or user agent. `--format json` is
|
|
235
|
+
lossless, with each submission's events nested under it.
|
|
236
|
+
- `submission delete` is permanent, cascades the submission's history, and needs a
|
|
237
|
+
**staff** role — a site user's API key gets a 403. It prompts for the id unless
|
|
238
|
+
`--yes` is passed. There is deliberately no equivalent in the admin UI, so don't go
|
|
239
|
+
looking for a delete button on the inbox.
|
|
240
|
+
|
|
241
|
+
## Images — never hotlink, always commit the file
|
|
242
|
+
|
|
243
|
+
**A `type: image` field is just a string.** Nothing in Scribe validates that its
|
|
244
|
+
value is an uploaded image — the schema doesn't know or care whether you write
|
|
245
|
+
`/uploads/some-photo.jpg` or `https://images.unsplash.com/photo-xyz`. When you (the
|
|
246
|
+
agent) are hand-authoring content files directly in a cloned repo — which is the
|
|
247
|
+
normal CLI workflow — **do not put an external image URL (Unsplash or otherwise)
|
|
248
|
+
into an image field's value.** It looks fine at first glance and then quietly means
|
|
249
|
+
the site depends forever on a third-party host it doesn't control, the image was
|
|
250
|
+
never actually reviewed/chosen for that specific listing, and it breaks the "this
|
|
251
|
+
site's content lives in this repo" model the rest of Scribe assumes.
|
|
252
|
+
|
|
253
|
+
Instead, every image an agent puts into content must be a **real file committed to
|
|
254
|
+
the site's own repo**, the same way the admin's own upload feature does it:
|
|
255
|
+
|
|
256
|
+
1. Get the actual image bytes — download it (a real stock photo, something the
|
|
257
|
+
user supplied, whatever the source) to a local temp file.
|
|
258
|
+
2. Save it into the cloned repo at `public/uploads/<name>`, with a name unique
|
|
259
|
+
enough not to collide (mirror the admin's own convention:
|
|
260
|
+
`<timestamp>-<safe-filename>`, letters/digits/`._-` only).
|
|
261
|
+
3. Reference it in the content file's frontmatter as `/uploads/<name>` — a
|
|
262
|
+
repo-relative path, not the original external URL.
|
|
263
|
+
4. Commit and push (`public/uploads/...` + the content file) together, same as any
|
|
264
|
+
other content change. There's no `scribe` CLI command for this — it's a plain
|
|
265
|
+
file in the repo, same as everything else.
|
|
266
|
+
|
|
267
|
+
This applies to `list: true` image fields too — one committed file per array entry,
|
|
268
|
+
same convention. A single-image field (`type: image`, no `list`) and a list one both
|
|
269
|
+
end up as `/uploads/...` paths; only the shape (string vs. array of strings)
|
|
270
|
+
differs.
|
|
271
|
+
|
|
272
|
+
The one case where a bare external URL is fine: the user explicitly says to leave a
|
|
273
|
+
placeholder / link out to somewhere else on purpose. Don't default to that — treat
|
|
274
|
+
"add a photo of X" as "go get a real image and commit it," not "find any URL that
|
|
275
|
+
renders."
|
|
276
|
+
|
|
277
|
+
## Deleting a site
|
|
278
|
+
|
|
279
|
+
```
|
|
280
|
+
scribe site delete <slug> [--yes]
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
Permanent — deletes the GitHub repo (schema, content, Astro source) and every Mongo
|
|
284
|
+
record for the site, which since RFC 0004 includes its submissions and their event
|
|
285
|
+
history. No undo. Prompts to retype the
|
|
286
|
+
slug unless `--yes` is passed. Requires an **unrestricted** API key (no
|
|
287
|
+
`siteSlugs` restriction) — same rule as `site add`; a key scoped to only that
|
|
288
|
+
site is rejected. Same action is in the admin UI: account menu → **Settings**
|
|
289
|
+
(super-admin only) → **Danger zone**.
|
|
290
|
+
|
|
291
|
+
Deleting also drops the site's domain from the Turnstile widget — but only if no
|
|
292
|
+
other site still uses that domain, so removing one demo never breaks the contact
|
|
293
|
+
form on the rest. A `Warning:` in the output means that cleanup didn't happen and
|
|
294
|
+
the stale domain is still on the widget; harmless, but tidy it up in Cloudflare.
|
|
295
|
+
|
|
296
|
+
Side effect worth knowing: if an API key's `siteSlugs` was only that one site,
|
|
297
|
+
deleting it leaves that key with an empty `siteSlugs` array — which the API treats
|
|
298
|
+
as **unrestricted** (every site), not inert. A key scoped to exactly the site you
|
|
299
|
+
delete comes out of it more powerful, not revoked.
|
|
300
|
+
|
|
301
|
+
## `.scribe.yml` field reference
|
|
302
|
+
|
|
303
|
+
Per collection: `name`, `label`, `path`, `format` (`yaml-frontmatter` for normal
|
|
304
|
+
content; also
|
|
305
|
+
`json-frontmatter`/`toml-frontmatter`/`yaml`/`json`/`toml`/`datagrid`/`code`/`raw`),
|
|
306
|
+
`fields[]`, `view` (`{primary, sort, fields, search, default}` — controls the admin
|
|
307
|
+
list view), and the Scribe extensions `description` (shown under the title on the
|
|
308
|
+
collection's list/inbox page in the admin), `form: true` (renders as an inbox),
|
|
309
|
+
`workflow: {states}` (form collections only), `notify: [...]` (recipients on new
|
|
310
|
+
submissions).
|
|
311
|
+
|
|
312
|
+
On a **form collection** specifically, since RFC 0004 moved submissions into Mongo:
|
|
313
|
+
|
|
314
|
+
- **Many per site is supported and normal.** Every `form: true` collection is an
|
|
315
|
+
independent inbox with its own workflow, badge and `?collection=` value; a schema
|
|
316
|
+
with two of them needs no special handling. The rate limiter is the one shared
|
|
317
|
+
thing — it's keyed per Site and IP, not per form.
|
|
318
|
+
- **Submitted values are coerced to the declared type** before validation, so a
|
|
319
|
+
form posting `FormData` works: a checkbox's `"on"` becomes `true`, `"4"` becomes
|
|
320
|
+
`4`. A value that can't be coerced still fails with a readable message. JSON
|
|
321
|
+
bodies, url-encoded and multipart are all accepted.
|
|
322
|
+
- `path` and `format` are **ignored** — there are no files to lay out. Leave them;
|
|
323
|
+
they're harmless, and the parser doesn't read them for a form collection.
|
|
324
|
+
- `workflow` takes `{states: [...]}` only. The old `field:` key is gone, and so is the
|
|
325
|
+
legacy shorthand where `states` was a list of bare strings — a schema still written
|
|
326
|
+
that way now fails to parse rather than being silently guessed at. Each state is an
|
|
327
|
+
object: `{name, label, kind, hidden}`, where `kind` is `needs-you` | `waiting` |
|
|
328
|
+
`closed` (it drives the inbox's nav badge, which counts `needs-you`) and `hidden`
|
|
329
|
+
keeps a state like `spam` out of the default view while leaving it selectable.
|
|
330
|
+
- **`status` and `createdAt` are reserved field names** and can't be declared in
|
|
331
|
+
`fields[]`. `status` is a real column on the submission and `submittedAt` replaces
|
|
332
|
+
`createdAt`; both render as columns the schema doesn't have to mention.
|
|
333
|
+
- **An `image` field is rejected** on a form collection. The old path uploaded a
|
|
334
|
+
Visitor's file straight into the public repo, and a Mongo document is the wrong place
|
|
335
|
+
for a blob. Deferred to RFC 0007 (object storage).
|
|
336
|
+
- **`notify:` does not send email.** It runs, but through a `ConsoleMailer` that only
|
|
337
|
+
logs — no address has ever received anything. Real delivery is RFC 0009. Don't
|
|
338
|
+
promise a client they'll be emailed on a new submission; the inbox and its
|
|
339
|
+
`needs-you` badge are the actual notification today.
|
|
340
|
+
|
|
341
|
+
Per field: `name`, `type`, `label`, `required`, `default`, `pattern`, `hidden`,
|
|
342
|
+
`readonly`, `isBody` (this field is the markdown body, not frontmatter), `list`
|
|
343
|
+
(`true` or `{min, max, collapsible}`), `options` (for `type: select`). For
|
|
344
|
+
`type: image` fields specifically, see **Images** above — its value (or each
|
|
345
|
+
value in the array, if `list`) must be a committed `/uploads/...` path, not an
|
|
346
|
+
external URL.
|
|
347
|
+
|
|
348
|
+
**Field `type` — stick to the implemented list.** The admin has a working editor
|
|
349
|
+
for: `string`, `text`, `rich-text`, `number`, `boolean`, `date`, `image`, `select`.
|
|
350
|
+
The type enum also accepts `block`, `code`, `file`, `object`, `reference`, `uuid` —
|
|
351
|
+
these parse fine but the admin has no editor for them yet, so a field using one
|
|
352
|
+
falls back to a raw/unstyled view. Don't reach for them unless that's specifically
|
|
353
|
+
acceptable for this site.
|
|
354
|
+
|
|
355
|
+
**Quote date- and time-like values in `string` fields.** When hand-writing a
|
|
356
|
+
content file, `when: 2026-09-28` or `at: 12:30` is read by Astro's YAML loader as
|
|
357
|
+
a Date and a number, not a string — so a field declared `type: string` fails its
|
|
358
|
+
generated `z.string()` schema and **the site build breaks**. Write
|
|
359
|
+
`when: "2026-09-28"`. Fields declared `type: date` are immune (they generate
|
|
360
|
+
`z.coerce.date()`, which takes either form), and anything written by the admin or
|
|
361
|
+
by `scribe content publish` is quoted for you. This only bites on raw hand-edited
|
|
362
|
+
files committed with `git push`.
|
|
363
|
+
|
|
364
|
+
## Gotchas
|
|
365
|
+
|
|
366
|
+
- Schema changes are instant locally, but cached **8 minutes** in the admin/API
|
|
367
|
+
after a push — don't chase "why hasn't this shown up yet" faster than that.
|
|
368
|
+
- `scribe site clone` is a thin `git clone` wrapper — once cloned, it's an ordinary
|
|
369
|
+
git repo, nothing more.
|
|
370
|
+
- A raw `git push` of content skips schema validation; `scribe content publish`
|
|
371
|
+
doesn't.
|
|
372
|
+
- There's no merge-conflict tooling anywhere in the CLI — resolve like any git
|
|
373
|
+
conflict.
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# Recipes
|
|
2
|
+
|
|
3
|
+
Patterns for building an Astro site against Scribe as the backend. Per
|
|
4
|
+
[RFC 0010](../../../docs/proposals/0010-agent-authored-forms.md) these are
|
|
5
|
+
**advice, not enforcement** — read the one you need, adapt it to the site you're
|
|
6
|
+
building, and ignore the rest. There is no template to conform to and no
|
|
7
|
+
generated config to keep in sync.
|
|
8
|
+
|
|
9
|
+
| Recipe | Use it when |
|
|
10
|
+
|---|---|
|
|
11
|
+
| [`schema.md`](./schema.md) | you need to know what collections and fields a Site has |
|
|
12
|
+
| [`collections.md`](./collections.md) | rendering content (posts, products, events) that editors manage in Scribe |
|
|
13
|
+
| [`forms.md`](./forms.md) | putting one or more forms on the Site |
|
|
14
|
+
| [`deploy.md`](./deploy.md) | setting up or debugging the build and deploy |
|
|
15
|
+
|
|
16
|
+
## The part that isn't advice
|
|
17
|
+
|
|
18
|
+
Scribe doesn't only serve the Site — it **writes into the Site's repo** and
|
|
19
|
+
triggers its build. The admin commits content files and uploaded images, writes
|
|
20
|
+
`public/CNAME` when a domain is verified, and `scribe build trigger` dispatches
|
|
21
|
+
the deploy workflow. So these five things are a contract, not a preference:
|
|
22
|
+
|
|
23
|
+
1. Content lives at each collection's `path`, in its `format`.
|
|
24
|
+
2. Images live under `public/` at the path stored in the item's frontmatter.
|
|
25
|
+
3. The presence of `public/CNAME` decides Astro's `base`.
|
|
26
|
+
4. The deploy workflow accepts `workflow_dispatch`.
|
|
27
|
+
5. Forms POST to `/api/submissions?site=<slug>&collection=<name>`.
|
|
28
|
+
|
|
29
|
+
Get one of these wrong and the failure shows up *after* an editor hits publish,
|
|
30
|
+
on the live site, not while you're building. Everything else — routing, styling,
|
|
31
|
+
page structure, which pages exist, where a form goes — is yours.
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# Recipe: rendering content collections
|
|
2
|
+
|
|
3
|
+
A content collection (anything without `form: true`) is a folder of files the
|
|
4
|
+
admin writes directly into the repo. Your job is to read them from the `path` the
|
|
5
|
+
schema declares, in the `format` it declares. That part isn't negotiable — the
|
|
6
|
+
admin commits those files and expects them there. How you route, style and lay
|
|
7
|
+
them out is entirely yours.
|
|
8
|
+
|
|
9
|
+
## The two formats
|
|
10
|
+
|
|
11
|
+
- **`yaml-frontmatter`** — markdown: `---` frontmatter, then a body. The body is
|
|
12
|
+
whichever field has `isBody: true`.
|
|
13
|
+
- **`yaml`** — a plain `.yml` data file, no body.
|
|
14
|
+
|
|
15
|
+
Files are named `<slug>.md` / `<slug>.yml`, and the slug is the URL slug.
|
|
16
|
+
|
|
17
|
+
## With Astro content collections
|
|
18
|
+
|
|
19
|
+
Glob the schema's paths. Astro wants its own `src/content.config.ts`, so keep the
|
|
20
|
+
loader list in step with `.scribe.yml` rather than hardcoding one Site's names:
|
|
21
|
+
|
|
22
|
+
```ts
|
|
23
|
+
// src/content.config.ts
|
|
24
|
+
import { defineCollection, z } from 'astro:content';
|
|
25
|
+
import { glob } from 'astro/loaders';
|
|
26
|
+
|
|
27
|
+
const cars = defineCollection({
|
|
28
|
+
loader: glob({ pattern: '**/*.md', base: './content/cars' }),
|
|
29
|
+
// Loose on purpose: an editor adding a field in Scribe shouldn't fail the
|
|
30
|
+
// build before you've had a chance to render it.
|
|
31
|
+
schema: z.object({
|
|
32
|
+
title: z.string(),
|
|
33
|
+
make: z.string().optional(),
|
|
34
|
+
year: z.number().optional(),
|
|
35
|
+
price: z.number().optional(),
|
|
36
|
+
images: z.array(z.string()).default([]),
|
|
37
|
+
active: z.boolean().default(true),
|
|
38
|
+
}).passthrough(),
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
export const collections = { cars };
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Then render as usual with `getCollection('cars')`.
|
|
45
|
+
|
|
46
|
+
Keep the zod schema **permissive** (`.passthrough()`, `.optional()`). A strict
|
|
47
|
+
schema turns "an editor added a field" into a failed deploy, and the editor has
|
|
48
|
+
no way to see why.
|
|
49
|
+
|
|
50
|
+
## Images
|
|
51
|
+
|
|
52
|
+
Uploaded images are committed under `public/` and the frontmatter stores the
|
|
53
|
+
**absolute site path** — `/images/cars/bugatti-chiron.jpg`. Two consequences:
|
|
54
|
+
|
|
55
|
+
- Don't use `astro:assets` `Image` on them; they aren't in `src/`. A plain `<img
|
|
56
|
+
src={...}>` is correct.
|
|
57
|
+
- On a project page (no custom domain) the Astro `base` is `/<repo>`, so prefix
|
|
58
|
+
stored paths with it or they 404 in the demo:
|
|
59
|
+
|
|
60
|
+
```astro
|
|
61
|
+
---
|
|
62
|
+
const base = import.meta.env.BASE_URL.replace(/\/$/, '');
|
|
63
|
+
---
|
|
64
|
+
<img src={`${base}${car.data.images[0]}`} alt={car.data.title} />
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
This is the single most common way a demo site looks broken while the custom
|
|
68
|
+
domain version looks fine.
|
|
69
|
+
|
|
70
|
+
## Don't hardcode one Client's collections
|
|
71
|
+
|
|
72
|
+
The same code gets reused across Sites whose collections are named differently.
|
|
73
|
+
Drive pages off the schema where it's cheap:
|
|
74
|
+
|
|
75
|
+
```astro
|
|
76
|
+
---
|
|
77
|
+
import { contentCollections } from '../lib/scribe';
|
|
78
|
+
const sections = contentCollections().filter((c) => c.name !== 'posts');
|
|
79
|
+
---
|
|
80
|
+
{sections.map((c) => <a href={`/${c.name}`}>{c.label ?? c.name}</a>)}
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
## Local content
|
|
84
|
+
|
|
85
|
+
`scribe content pull <site>` fetches the live content into the repo so you can
|
|
86
|
+
build against real data. A Site also usually keeps a `content/` folder committed,
|
|
87
|
+
which is what the deploy builds from.
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# Recipe: build and deploy
|
|
2
|
+
|
|
3
|
+
Sites deploy to GitHub Pages from their own repo. Two parts of this are a
|
|
4
|
+
contract with Scribe rather than a preference: the workflow must accept
|
|
5
|
+
`workflow_dispatch`, and the Astro `base` must follow `public/CNAME`.
|
|
6
|
+
|
|
7
|
+
## Why `workflow_dispatch` matters
|
|
8
|
+
|
|
9
|
+
When an editor publishes in the admin, Scribe commits the content file and then
|
|
10
|
+
calls `scribe build trigger` → `dispatchWorkflow`. Without
|
|
11
|
+
`workflow_dispatch: {}` the dispatch fails and **the editor's change never goes
|
|
12
|
+
live** even though the admin says it saved. Keep it even if you also build on
|
|
13
|
+
push.
|
|
14
|
+
|
|
15
|
+
## Why the base path follows CNAME
|
|
16
|
+
|
|
17
|
+
A bare project page lives at `https://<org>.github.io/<repo>/`, so Astro needs
|
|
18
|
+
`base: '/<repo>'`. Once a custom domain is verified, the admin writes
|
|
19
|
+
`public/CNAME` and the site serves from the root, so `base` must become `/`.
|
|
20
|
+
Deciding this in the workflow means a domain going live needs no code change.
|
|
21
|
+
|
|
22
|
+
```yaml
|
|
23
|
+
# .github/workflows/deploy.yml
|
|
24
|
+
name: Deploy to GitHub Pages
|
|
25
|
+
|
|
26
|
+
on:
|
|
27
|
+
push:
|
|
28
|
+
branches: [main]
|
|
29
|
+
workflow_dispatch: {} # required — Scribe dispatches this on publish
|
|
30
|
+
|
|
31
|
+
permissions:
|
|
32
|
+
contents: read
|
|
33
|
+
pages: write
|
|
34
|
+
id-token: write
|
|
35
|
+
|
|
36
|
+
concurrency:
|
|
37
|
+
group: pages
|
|
38
|
+
cancel-in-progress: true
|
|
39
|
+
|
|
40
|
+
jobs:
|
|
41
|
+
build:
|
|
42
|
+
runs-on: ubuntu-latest
|
|
43
|
+
steps:
|
|
44
|
+
- uses: actions/checkout@v4
|
|
45
|
+
- uses: actions/setup-node@v4
|
|
46
|
+
with:
|
|
47
|
+
node-version: 22
|
|
48
|
+
- run: npm install
|
|
49
|
+
|
|
50
|
+
- name: Compute Pages base path
|
|
51
|
+
id: base
|
|
52
|
+
run: |
|
|
53
|
+
if [ -f public/CNAME ]; then
|
|
54
|
+
echo "path=/" >> "$GITHUB_OUTPUT"
|
|
55
|
+
else
|
|
56
|
+
echo "path=/${{ github.event.repository.name }}" >> "$GITHUB_OUTPUT"
|
|
57
|
+
fi
|
|
58
|
+
|
|
59
|
+
- run: npm run build
|
|
60
|
+
env:
|
|
61
|
+
PUBLIC_SCRIBE_API_URL: ${{ vars.PUBLIC_SCRIBE_API_URL }}
|
|
62
|
+
PUBLIC_BASE_PATH: ${{ steps.base.outputs.path }}
|
|
63
|
+
|
|
64
|
+
- uses: actions/upload-pages-artifact@v3
|
|
65
|
+
with:
|
|
66
|
+
path: dist
|
|
67
|
+
|
|
68
|
+
deploy:
|
|
69
|
+
needs: build
|
|
70
|
+
runs-on: ubuntu-latest
|
|
71
|
+
environment:
|
|
72
|
+
name: github-pages
|
|
73
|
+
steps:
|
|
74
|
+
- id: deployment
|
|
75
|
+
uses: actions/deploy-pages@v4
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
And in `astro.config.mjs`:
|
|
79
|
+
|
|
80
|
+
```js
|
|
81
|
+
export default defineConfig({
|
|
82
|
+
base: process.env.PUBLIC_BASE_PATH || '/',
|
|
83
|
+
// ...
|
|
84
|
+
});
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
## `PUBLIC_SCRIBE_API_URL`
|
|
88
|
+
|
|
89
|
+
Where forms post. It's a **repo variable**, and GitHub resolves an unset
|
|
90
|
+
`vars.*` to an empty string rather than leaving it undefined — so `??` won't
|
|
91
|
+
fall back. Use `||`:
|
|
92
|
+
|
|
93
|
+
```ts
|
|
94
|
+
const apiBase = import.meta.env.PUBLIC_SCRIBE_API_URL || 'https://scribe.weaverdigital.io';
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Any `PUBLIC_*` value is inlined into the client bundle at build time, so changing
|
|
98
|
+
it needs a rebuild, not just a redeploy.
|
|
99
|
+
|
|
100
|
+
## Local loop
|
|
101
|
+
|
|
102
|
+
```
|
|
103
|
+
npm install
|
|
104
|
+
npm run dev # iterate on design
|
|
105
|
+
npm run build && npm run preview
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
To test forms locally, point `PUBLIC_SCRIBE_API_URL` at a local admin
|
|
109
|
+
(`http://localhost:3040`) — but note the endpoint only allows CORS from the
|
|
110
|
+
Site's registered domain, so use the admin's own host or expect to test
|
|
111
|
+
submissions against the deployed site.
|
|
112
|
+
|
|
113
|
+
## Checklist when a deploy looks wrong
|
|
114
|
+
|
|
115
|
+
- Images 404 on the demo URL but work on the custom domain → missing `base`
|
|
116
|
+
prefix (see `collections.md`).
|
|
117
|
+
- Editor published but the site is stale → `workflow_dispatch` missing, or the
|
|
118
|
+
workflow failed; check the Actions tab.
|
|
119
|
+
- Form says "Failed to fetch" → `PUBLIC_SCRIBE_API_URL` baked in wrong, or the
|
|
120
|
+
origin isn't the Site's registered domain.
|
|
121
|
+
- Build fails after an editor added a field → the content zod schema is too
|
|
122
|
+
strict (see `collections.md`).
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
# Recipe: forms
|
|
2
|
+
|
|
3
|
+
A Site can have **as many forms as it likes**. Each one is a collection in
|
|
4
|
+
`.scribe.yml` with `form: true`, each gets its own inbox and its own workflow in
|
|
5
|
+
the admin, and each posts to the same endpoint with a different `collection`.
|
|
6
|
+
|
|
7
|
+
Design the form however you want. Only the POST contract below is fixed.
|
|
8
|
+
|
|
9
|
+
## 1. Declare the collection
|
|
10
|
+
|
|
11
|
+
Write a spec file and apply it — don't hand-edit `.scribe.yml` in the repo, since
|
|
12
|
+
the admin treats the committed file as the source of truth and will overwrite
|
|
13
|
+
you:
|
|
14
|
+
|
|
15
|
+
```yaml
|
|
16
|
+
# rsvps.yml
|
|
17
|
+
name: rsvps
|
|
18
|
+
label: Event RSVPs
|
|
19
|
+
description: Replies to the invitations on the events page.
|
|
20
|
+
path: content/rsvps
|
|
21
|
+
format: yaml
|
|
22
|
+
form: true
|
|
23
|
+
fields:
|
|
24
|
+
- { name: fullName, type: string, required: true }
|
|
25
|
+
- { name: email, type: string, required: true }
|
|
26
|
+
- { name: mobile, type: string, label: Mobile }
|
|
27
|
+
- { name: eventTitle, type: string, label: Event, required: true }
|
|
28
|
+
- { name: guests, type: number }
|
|
29
|
+
- { name: attending, type: boolean, label: I'll be there }
|
|
30
|
+
- { name: dietaryNotes, type: text }
|
|
31
|
+
workflow:
|
|
32
|
+
states:
|
|
33
|
+
- { name: pending, label: Awaiting reply, kind: needs-you }
|
|
34
|
+
- { name: waitlist, label: Waitlisted, kind: waiting }
|
|
35
|
+
- { name: confirmed, label: Confirmed, kind: closed }
|
|
36
|
+
- { name: declined, label: Declined, kind: closed }
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
```
|
|
40
|
+
scribe schema apply <site> --file rsvps.yml # or --file - to pipe it in
|
|
41
|
+
scribe schema show <site> rsvps # read it back
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Rules the apply will enforce for you:
|
|
45
|
+
|
|
46
|
+
- `status` and `createdAt` are **reserved** — they're columns on the submission,
|
|
47
|
+
not fields you declare.
|
|
48
|
+
- A form collection may **not** declare an `image` field; a visitor-submitted
|
|
49
|
+
file has nowhere to be stored yet (RFC 0007).
|
|
50
|
+
- `workflow.states` is a list of `{ name, label, kind }` where `kind` is
|
|
51
|
+
`needs-you`, `waiting` or `closed`. A new submission starts in the first
|
|
52
|
+
`needs-you` state, so order matters. `hidden: true` keeps a state out of the
|
|
53
|
+
filter dropdown.
|
|
54
|
+
- `path` and `format` are ignored for a form collection (submissions live in the
|
|
55
|
+
database), but the parser still wants them.
|
|
56
|
+
|
|
57
|
+
## 2. POST contract
|
|
58
|
+
|
|
59
|
+
```
|
|
60
|
+
POST /api/submissions?site=<slug>&collection=<name>
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
- **Body:** JSON, or `application/x-www-form-urlencoded`, or
|
|
64
|
+
`multipart/form-data`. Values are coerced to the declared types, so a checkbox
|
|
65
|
+
posting `"on"` becomes `true` and `"4"` becomes `4`. Posting real JSON types is
|
|
66
|
+
still better — it's unambiguous.
|
|
67
|
+
- **Turnstile is required.** Send the widget's token as `cf-turnstile-response`.
|
|
68
|
+
A missing or empty token is a **401**, always — there is no bypass.
|
|
69
|
+
- **Responses:** `200 {ok:true}`; `400` with a human-readable `error` for a
|
|
70
|
+
validation failure; `401` verification failed; `429` rate limited (per site and
|
|
71
|
+
IP, **shared across all of that Site's forms**); `503` with visitor-facing copy
|
|
72
|
+
in `error` when the database is unreachable — show that message as-is, it's
|
|
73
|
+
written for them; `502` when the Site's `.scribe.yml` is unusable.
|
|
74
|
+
- **CORS** is allowed only from the Site's registered domain, so test from the
|
|
75
|
+
real host (or `localhost` against a local admin).
|
|
76
|
+
|
|
77
|
+
Never report success on a non-2xx. A form that says "Thanks!" after a 503 loses
|
|
78
|
+
the message.
|
|
79
|
+
|
|
80
|
+
## 3. A form component
|
|
81
|
+
|
|
82
|
+
Generic and schema-driven, which is the useful starting point when the fields
|
|
83
|
+
come from `.scribe.yml`. Adapt or replace the markup freely.
|
|
84
|
+
|
|
85
|
+
```astro
|
|
86
|
+
---
|
|
87
|
+
// src/components/ScribeForm.astro
|
|
88
|
+
export interface Props {
|
|
89
|
+
site: string;
|
|
90
|
+
collection: string;
|
|
91
|
+
turnstileSiteKey: string;
|
|
92
|
+
fields: Array<{ name: string; label?: string; type: string; required?: boolean }>;
|
|
93
|
+
apiBase?: string;
|
|
94
|
+
submitLabel?: string;
|
|
95
|
+
/** Values the page knows that the visitor shouldn't retype, e.g. which event this is. */
|
|
96
|
+
prefill?: Record<string, string>;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const {
|
|
100
|
+
site, collection, turnstileSiteKey, fields,
|
|
101
|
+
apiBase = import.meta.env.PUBLIC_SCRIBE_API_URL || 'https://scribe.weaverdigital.io',
|
|
102
|
+
submitLabel = 'Send',
|
|
103
|
+
prefill = {},
|
|
104
|
+
} = Astro.props;
|
|
105
|
+
|
|
106
|
+
// Scoped to the collection so two forms can share a page.
|
|
107
|
+
const formId = `scribe-form-${collection}`;
|
|
108
|
+
|
|
109
|
+
function labelFor(f: { name: string; label?: string }) {
|
|
110
|
+
return f.label ?? f.name.replace(/([a-z0-9])([A-Z])/g, '$1 $2').replace(/[_-]+/g, ' ')
|
|
111
|
+
.replace(/^./, (c) => c.toUpperCase());
|
|
112
|
+
}
|
|
113
|
+
// Control comes from the declared type. Name-based guessing is only for the
|
|
114
|
+
// keyboard hint on an email/phone input — never for required-ness or the control.
|
|
115
|
+
function inputType(f: { name: string; type: string }) {
|
|
116
|
+
if (f.type === 'number') return 'number';
|
|
117
|
+
if (/mail/i.test(f.name)) return 'email';
|
|
118
|
+
if (/phone|mobile|tel|cell/i.test(f.name)) return 'tel';
|
|
119
|
+
return 'text';
|
|
120
|
+
}
|
|
121
|
+
---
|
|
122
|
+
<form id={formId} data-site={site} data-collection={collection} data-endpoint={`${apiBase}/api/submissions`}>
|
|
123
|
+
{fields.map((f) => (
|
|
124
|
+
prefill[f.name] !== undefined ? (
|
|
125
|
+
<input type="hidden" name={f.name} value={prefill[f.name]} />
|
|
126
|
+
) : f.type === 'boolean' ? (
|
|
127
|
+
<label class="checkbox">
|
|
128
|
+
<input name={f.name} type="checkbox" /> {labelFor(f)}
|
|
129
|
+
</label>
|
|
130
|
+
) : f.type === 'text' ? (
|
|
131
|
+
<label>{labelFor(f)}<textarea name={f.name} required={f.required} rows="4"></textarea></label>
|
|
132
|
+
) : (
|
|
133
|
+
<label>{labelFor(f)}<input name={f.name} type={inputType(f)} required={f.required} /></label>
|
|
134
|
+
)
|
|
135
|
+
))}
|
|
136
|
+
<div class="cf-turnstile" data-sitekey={turnstileSiteKey}></div>
|
|
137
|
+
<button type="submit">{submitLabel}</button>
|
|
138
|
+
<p class="form-status" role="status" aria-live="polite"></p>
|
|
139
|
+
</form>
|
|
140
|
+
|
|
141
|
+
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
|
|
142
|
+
<script is:inline define:vars={{ formId }}>
|
|
143
|
+
(function () {
|
|
144
|
+
const form = document.getElementById(formId);
|
|
145
|
+
if (!form) return;
|
|
146
|
+
const statusEl = form.querySelector('.form-status');
|
|
147
|
+
const say = (m) => { if (statusEl) statusEl.textContent = m; };
|
|
148
|
+
|
|
149
|
+
form.addEventListener('submit', async (event) => {
|
|
150
|
+
event.preventDefault();
|
|
151
|
+
|
|
152
|
+
// Built per control rather than from FormData, so a checkbox sends a real
|
|
153
|
+
// boolean and an unchecked box sends `false` instead of being omitted.
|
|
154
|
+
const payload = {};
|
|
155
|
+
for (const el of form.elements) {
|
|
156
|
+
if (!el.name) continue;
|
|
157
|
+
if (el.type === 'checkbox') payload[el.name] = el.checked;
|
|
158
|
+
else if (el.type === 'number') payload[el.name] = el.value === '' ? null : Number(el.value);
|
|
159
|
+
else payload[el.name] = el.value;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const url = `${form.dataset.endpoint}?site=${encodeURIComponent(form.dataset.site)}`
|
|
163
|
+
+ `&collection=${encodeURIComponent(form.dataset.collection)}`;
|
|
164
|
+
|
|
165
|
+
say('Sending…');
|
|
166
|
+
try {
|
|
167
|
+
const res = await fetch(url, {
|
|
168
|
+
method: 'POST',
|
|
169
|
+
headers: { 'Content-Type': 'application/json' },
|
|
170
|
+
body: JSON.stringify(payload),
|
|
171
|
+
});
|
|
172
|
+
if (!res.ok) {
|
|
173
|
+
// The server's copy is written for the visitor on 400/503; prefer it.
|
|
174
|
+
const data = await res.json().catch(() => ({}));
|
|
175
|
+
say(data.error || "Sorry — we couldn't send that. Please try again.");
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
form.reset();
|
|
179
|
+
say('Thanks — we got it.');
|
|
180
|
+
} catch {
|
|
181
|
+
say("Sorry — we couldn't reach us just now. Please call or email instead.");
|
|
182
|
+
}
|
|
183
|
+
});
|
|
184
|
+
})();
|
|
185
|
+
</script>
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
Note `cf-turnstile-response`: the Turnstile widget injects a hidden input with
|
|
189
|
+
that name into the form, so the loop above picks it up automatically. If you
|
|
190
|
+
build the payload by hand, include it explicitly.
|
|
191
|
+
|
|
192
|
+
## 4. Put it on a page
|
|
193
|
+
|
|
194
|
+
Placement is entirely yours — the schema says nothing about where a form goes.
|
|
195
|
+
Read the collection out of the schema and pass its fields in:
|
|
196
|
+
|
|
197
|
+
```astro
|
|
198
|
+
---
|
|
199
|
+
// src/pages/events/[...slug].astro
|
|
200
|
+
import ScribeForm from '../../components/ScribeForm.astro';
|
|
201
|
+
import { loadScribeSchema } from '../../lib/scribe'; // see schema.md
|
|
202
|
+
|
|
203
|
+
const schema = loadScribeSchema();
|
|
204
|
+
const rsvps = schema.collections.find((c) => c.name === 'rsvps');
|
|
205
|
+
const { event } = Astro.props;
|
|
206
|
+
---
|
|
207
|
+
<h2>Coming along?</h2>
|
|
208
|
+
<ScribeForm
|
|
209
|
+
site={schema.site}
|
|
210
|
+
collection="rsvps"
|
|
211
|
+
turnstileSiteKey={schema.turnstileSiteKey}
|
|
212
|
+
fields={rsvps.fields}
|
|
213
|
+
submitLabel="Send my RSVP"
|
|
214
|
+
prefill={{ eventTitle: event.data.title }}
|
|
215
|
+
/>
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
`prefill` is how a form on a detail page captures its context — the visitor
|
|
219
|
+
never types which event they're replying to, and the submission arrives in the
|
|
220
|
+
inbox already saying so.
|
|
221
|
+
|
|
222
|
+
## Gotchas
|
|
223
|
+
|
|
224
|
+
- **Two forms, one page:** fine, as long as element ids are unique. The snippet
|
|
225
|
+
above scopes everything to `scribe-form-<collection>`.
|
|
226
|
+
- **`notify:` does not send email.** It's parsed and logged by a console mailer
|
|
227
|
+
only. Don't promise a client notifications (RFC 0009 is the real thing).
|
|
228
|
+
- **Rate limiting is per Site and IP, not per form**, so hammering one form while
|
|
229
|
+
testing will also throttle the others.
|
|
230
|
+
- **An unchecked checkbox in `FormData` is absent, not `false`.** The payload
|
|
231
|
+
loop above avoids this; if you use `FormData`, add a hidden companion input.
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# Recipe: reading the schema
|
|
2
|
+
|
|
3
|
+
`.scribe.yml` sits in the Site's repo root and is the contract between Scribe and
|
|
4
|
+
the Site: it names the collections, their fields, where content lives, and which
|
|
5
|
+
collections are forms. The admin keeps it current — editors change it through the
|
|
6
|
+
UI and the CLI writes to it — so the committed copy is authoritative at build
|
|
7
|
+
time.
|
|
8
|
+
|
|
9
|
+
**Read it directly. Don't generate a config file from it.** Older sites have a
|
|
10
|
+
`scripts/generate-config.mjs` that flattened the schema into a `site-config.ts`;
|
|
11
|
+
that layer lost field types and could only ever represent one form, which is why
|
|
12
|
+
RFC 0010 drops it.
|
|
13
|
+
|
|
14
|
+
## A loader
|
|
15
|
+
|
|
16
|
+
```ts
|
|
17
|
+
// src/lib/scribe.ts
|
|
18
|
+
import { readFileSync } from 'node:fs';
|
|
19
|
+
import { resolve } from 'node:path';
|
|
20
|
+
import { parse } from 'yaml';
|
|
21
|
+
|
|
22
|
+
export interface ScribeField {
|
|
23
|
+
name: string;
|
|
24
|
+
type: 'string' | 'text' | 'number' | 'boolean' | 'date' | 'select' | 'rich-text' | 'image';
|
|
25
|
+
label?: string;
|
|
26
|
+
required?: boolean;
|
|
27
|
+
hidden?: boolean;
|
|
28
|
+
isBody?: boolean;
|
|
29
|
+
options?: Array<string | { value: string; label?: string }>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface ScribeCollection {
|
|
33
|
+
name: string;
|
|
34
|
+
path: string;
|
|
35
|
+
format?: 'yaml' | 'yaml-frontmatter';
|
|
36
|
+
label?: string;
|
|
37
|
+
description?: string;
|
|
38
|
+
form?: boolean;
|
|
39
|
+
fields: ScribeField[];
|
|
40
|
+
view?: { primary?: string; sort?: string; fields?: string[] };
|
|
41
|
+
workflow?: { states?: Array<{ name: string; label?: string; kind?: string; hidden?: boolean }> };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface ScribeSchema {
|
|
45
|
+
site: string;
|
|
46
|
+
siteName?: string;
|
|
47
|
+
turnstileSiteKey?: string;
|
|
48
|
+
collections: ScribeCollection[];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
let cached: ScribeSchema | undefined;
|
|
52
|
+
|
|
53
|
+
/** Build-time only — reads the repo's own `.scribe.yml`. */
|
|
54
|
+
export function loadScribeSchema(): ScribeSchema {
|
|
55
|
+
// Resolve from the working directory, NOT from `import.meta.url`. Astro
|
|
56
|
+
// bundles this module into `dist/.prerender/chunks/`, so a module-relative
|
|
57
|
+
// path looks for `.scribe.yml` next to the bundle and fails the build with
|
|
58
|
+
// `ENOENT: .../dist/.scribe.yml`.
|
|
59
|
+
cached ??= parse(readFileSync(resolve(process.cwd(), '.scribe.yml'), 'utf-8')) as ScribeSchema;
|
|
60
|
+
return cached;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export const formCollections = () => loadScribeSchema().collections.filter((c) => c.form === true);
|
|
64
|
+
export const contentCollections = () => loadScribeSchema().collections.filter((c) => c.form !== true);
|
|
65
|
+
export const collection = (name: string) =>
|
|
66
|
+
loadScribeSchema().collections.find((c) => c.name === name);
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Needs `yaml` in the Site's dependencies (a `devDependency` is fine — this only
|
|
70
|
+
runs at build time, and CI installs dev deps before `npm run build`).
|
|
71
|
+
|
|
72
|
+
## What the keys mean
|
|
73
|
+
|
|
74
|
+
| Key | Meaning |
|
|
75
|
+
|---|---|
|
|
76
|
+
| `site` | the Site slug — what you pass as `?site=` when submitting a form |
|
|
77
|
+
| `siteName` | display name |
|
|
78
|
+
| `turnstileSiteKey` | public Turnstile key for forms |
|
|
79
|
+
| `collections[].path` | where content files live, relative to the repo root |
|
|
80
|
+
| `collections[].format` | `yaml-frontmatter` (markdown with a body) or `yaml` (data only) |
|
|
81
|
+
| `collections[].form` | `true` means submissions, not files — **no content to render** |
|
|
82
|
+
| `fields[].isBody` | this field is the markdown body, not frontmatter |
|
|
83
|
+
| `fields[].hidden` | internal; don't render it, don't put it on a form |
|
|
84
|
+
| `view.primary` / `view.sort` | the editor's preferred title and sort field |
|
|
85
|
+
|
|
86
|
+
A `form: true` collection has **no files in the repo** — its submissions are rows
|
|
87
|
+
in Scribe's database, readable in the admin or via
|
|
88
|
+
`scribe submission list`/`export`. Don't try to render it as content.
|
|
89
|
+
|
|
90
|
+
## From the CLI instead
|
|
91
|
+
|
|
92
|
+
Useful when you want the schema without a checkout, or to inspect it while
|
|
93
|
+
working:
|
|
94
|
+
|
|
95
|
+
```
|
|
96
|
+
scribe schema show <site> # readable summary
|
|
97
|
+
scribe schema show <site> rsvps # one collection
|
|
98
|
+
scribe schema show <site> --json # raw, for piping
|
|
99
|
+
```
|