bananahub 0.1.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/LICENSE +21 -0
- package/README.md +142 -0
- package/bin/bananahub.js +121 -0
- package/lib/color.js +11 -0
- package/lib/commands/add.js +376 -0
- package/lib/commands/info.js +74 -0
- package/lib/commands/init.js +214 -0
- package/lib/commands/list.js +38 -0
- package/lib/commands/registry-cmd.js +15 -0
- package/lib/commands/remove.js +25 -0
- package/lib/commands/search.js +277 -0
- package/lib/commands/update.js +48 -0
- package/lib/commands/validate-cmd.js +42 -0
- package/lib/constants.js +19 -0
- package/lib/frontmatter.js +130 -0
- package/lib/github.js +79 -0
- package/lib/hub.js +103 -0
- package/lib/registry.js +68 -0
- package/lib/validate.js +236 -0
- package/package.json +38 -0
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { resolve } from 'node:path';
|
|
2
|
+
import { validateTemplate } from '../validate.js';
|
|
3
|
+
import { bold, green, red, yellow, dim } from '../color.js';
|
|
4
|
+
|
|
5
|
+
export async function validateCommand(args) {
|
|
6
|
+
const targetPath = args[0] ? resolve(args[0]) : process.cwd();
|
|
7
|
+
|
|
8
|
+
console.log(dim(`\n Validating: ${targetPath}\n`));
|
|
9
|
+
|
|
10
|
+
const result = await validateTemplate(targetPath);
|
|
11
|
+
|
|
12
|
+
if (result.errors.length > 0) {
|
|
13
|
+
console.log(red(bold(' ERRORS:')));
|
|
14
|
+
for (const e of result.errors) {
|
|
15
|
+
console.log(red(` - ${e}`));
|
|
16
|
+
}
|
|
17
|
+
console.log();
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
if (result.warnings.length > 0) {
|
|
21
|
+
console.log(yellow(bold(' WARNINGS:')));
|
|
22
|
+
for (const w of result.warnings) {
|
|
23
|
+
console.log(yellow(` - ${w}`));
|
|
24
|
+
}
|
|
25
|
+
console.log();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (result.meta) {
|
|
29
|
+
console.log(dim(` ID: ${result.meta.id || '(not set)'}`));
|
|
30
|
+
console.log(dim(` Type: ${result.meta.type || 'prompt'}`));
|
|
31
|
+
console.log(dim(` Title: ${result.meta.title || '(not set)'}`));
|
|
32
|
+
console.log(dim(` Profile: ${result.meta.profile || '(not set)'}`));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (result.valid) {
|
|
36
|
+
console.log(green(bold('\n VALID')));
|
|
37
|
+
} else {
|
|
38
|
+
console.log(red(bold('\n INVALID')));
|
|
39
|
+
process.exitCode = 1;
|
|
40
|
+
}
|
|
41
|
+
console.log();
|
|
42
|
+
}
|
package/lib/constants.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { homedir } from 'node:os';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
|
|
4
|
+
export const CLI_VERSION = '0.1.0';
|
|
5
|
+
export const TEMPLATES_DIR = join(homedir(), '.config', 'nanobanana', 'templates');
|
|
6
|
+
export const REGISTRY_FILE = '.registry.json';
|
|
7
|
+
export const SOURCE_FILE = '.source.json';
|
|
8
|
+
export const GITHUB_API = 'https://api.github.com';
|
|
9
|
+
export const HUB_API = 'https://bananahub-api.zhan9kun.workers.dev/api';
|
|
10
|
+
export const HUB_SITE = 'https://nano-banana-hub.github.io';
|
|
11
|
+
export const HUB_CATALOG_URL = `${HUB_SITE}/catalog.json`;
|
|
12
|
+
|
|
13
|
+
export const VALID_PROFILES = [
|
|
14
|
+
'photo', 'illustration', 'diagram', 'text-heavy',
|
|
15
|
+
'minimal', 'sticker', '3d', 'product', 'concept-art', 'general'
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
export const VALID_DIFFICULTIES = ['beginner', 'intermediate', 'advanced'];
|
|
19
|
+
export const VALID_TEMPLATE_TYPES = ['prompt', 'workflow'];
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal YAML frontmatter parser.
|
|
3
|
+
* Handles the subset used by template.md files (scalars, arrays, objects-in-arrays).
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export function parseFrontmatter(text) {
|
|
7
|
+
const match = text.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
|
8
|
+
if (!match) return null;
|
|
9
|
+
const yaml = match[1];
|
|
10
|
+
return parseYaml(yaml);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function parseYaml(text) {
|
|
14
|
+
const result = {};
|
|
15
|
+
const lines = text.split(/\r?\n/);
|
|
16
|
+
let i = 0;
|
|
17
|
+
|
|
18
|
+
while (i < lines.length) {
|
|
19
|
+
const line = lines[i];
|
|
20
|
+
|
|
21
|
+
// Skip blank lines and comments
|
|
22
|
+
if (!line.trim() || line.trim().startsWith('#')) {
|
|
23
|
+
i++;
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const keyMatch = line.match(/^(\w[\w_]*):\s*(.*)/);
|
|
28
|
+
if (!keyMatch) { i++; continue; }
|
|
29
|
+
|
|
30
|
+
const key = keyMatch[1];
|
|
31
|
+
let value = keyMatch[2].trim();
|
|
32
|
+
|
|
33
|
+
// Inline array: [a, b, c]
|
|
34
|
+
if (value.startsWith('[')) {
|
|
35
|
+
result[key] = parseInlineArray(value);
|
|
36
|
+
i++;
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Quoted string
|
|
41
|
+
if ((value.startsWith('"') && value.endsWith('"')) ||
|
|
42
|
+
(value.startsWith("'") && value.endsWith("'"))) {
|
|
43
|
+
result[key] = value.slice(1, -1);
|
|
44
|
+
i++;
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Empty value — check for block array/object below
|
|
49
|
+
if (value === '') {
|
|
50
|
+
// Look ahead for block array (lines starting with " - ")
|
|
51
|
+
const items = [];
|
|
52
|
+
let j = i + 1;
|
|
53
|
+
while (j < lines.length && lines[j].match(/^ - /)) {
|
|
54
|
+
const itemLine = lines[j].replace(/^ - /, '').trim();
|
|
55
|
+
// Check if this array item has sub-keys
|
|
56
|
+
let k = j + 1;
|
|
57
|
+
const subKeys = {};
|
|
58
|
+
let hasSubKeys = false;
|
|
59
|
+
while (k < lines.length && lines[k].match(/^ \w/)) {
|
|
60
|
+
const subMatch = lines[k].match(/^ (\w[\w_]*):\s*(.*)/);
|
|
61
|
+
if (subMatch) {
|
|
62
|
+
hasSubKeys = true;
|
|
63
|
+
let sv = subMatch[2].trim();
|
|
64
|
+
if ((sv.startsWith('"') && sv.endsWith('"')) ||
|
|
65
|
+
(sv.startsWith("'") && sv.endsWith("'"))) {
|
|
66
|
+
sv = sv.slice(1, -1);
|
|
67
|
+
}
|
|
68
|
+
subKeys[subMatch[1]] = coerce(sv);
|
|
69
|
+
}
|
|
70
|
+
k++;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (hasSubKeys) {
|
|
74
|
+
// First line of block item may have a key: value too
|
|
75
|
+
const firstKeyMatch = itemLine.match(/^(\w[\w_]*):\s*(.*)/);
|
|
76
|
+
if (firstKeyMatch) {
|
|
77
|
+
let fv = firstKeyMatch[2].trim();
|
|
78
|
+
if ((fv.startsWith('"') && fv.endsWith('"')) ||
|
|
79
|
+
(fv.startsWith("'") && fv.endsWith("'"))) {
|
|
80
|
+
fv = fv.slice(1, -1);
|
|
81
|
+
}
|
|
82
|
+
subKeys[firstKeyMatch[1]] = coerce(fv);
|
|
83
|
+
}
|
|
84
|
+
items.push(subKeys);
|
|
85
|
+
j = k;
|
|
86
|
+
} else {
|
|
87
|
+
items.push(coerce(itemLine));
|
|
88
|
+
j++;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (items.length > 0) {
|
|
93
|
+
result[key] = items;
|
|
94
|
+
i = j;
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
result[key] = '';
|
|
99
|
+
i++;
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Plain scalar
|
|
104
|
+
result[key] = coerce(value);
|
|
105
|
+
i++;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return result;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function parseInlineArray(str) {
|
|
112
|
+
const inner = str.slice(1, -1);
|
|
113
|
+
return inner.split(',').map(s => {
|
|
114
|
+
s = s.trim();
|
|
115
|
+
if ((s.startsWith('"') && s.endsWith('"')) ||
|
|
116
|
+
(s.startsWith("'") && s.endsWith("'"))) {
|
|
117
|
+
return s.slice(1, -1);
|
|
118
|
+
}
|
|
119
|
+
return s;
|
|
120
|
+
}).filter(Boolean);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function coerce(val) {
|
|
124
|
+
if (val === 'true') return true;
|
|
125
|
+
if (val === 'false') return false;
|
|
126
|
+
if (val === 'null' || val === '~') return null;
|
|
127
|
+
if (/^\d+$/.test(val)) return parseInt(val, 10);
|
|
128
|
+
if (/^\d+\.\d+$/.test(val)) return parseFloat(val);
|
|
129
|
+
return val;
|
|
130
|
+
}
|
package/lib/github.js
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { GITHUB_API } from './constants.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Fetch repo info from GitHub API.
|
|
5
|
+
*/
|
|
6
|
+
export async function getRepoInfo(repo) {
|
|
7
|
+
const res = await fetch(`${GITHUB_API}/repos/${repo}`, {
|
|
8
|
+
headers: ghHeaders()
|
|
9
|
+
});
|
|
10
|
+
if (!res.ok) {
|
|
11
|
+
if (res.status === 404) throw new Error(`Repository not found: ${repo}`);
|
|
12
|
+
throw new Error(`GitHub API error: ${res.status} ${res.statusText}`);
|
|
13
|
+
}
|
|
14
|
+
return res.json();
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Check if a file exists at the repo root.
|
|
19
|
+
*/
|
|
20
|
+
export async function getFileContent(repo, path, ref = 'HEAD') {
|
|
21
|
+
const res = await fetch(`${GITHUB_API}/repos/${repo}/contents/${path}`, {
|
|
22
|
+
headers: ghHeaders()
|
|
23
|
+
});
|
|
24
|
+
if (!res.ok) return null;
|
|
25
|
+
const data = await res.json();
|
|
26
|
+
if (data.encoding === 'base64' && data.content) {
|
|
27
|
+
return Buffer.from(data.content, 'base64').toString('utf8');
|
|
28
|
+
}
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Get the default branch and latest commit SHA.
|
|
34
|
+
*/
|
|
35
|
+
export async function getDefaultBranchInfo(repo) {
|
|
36
|
+
const info = await getRepoInfo(repo);
|
|
37
|
+
return {
|
|
38
|
+
branch: info.default_branch,
|
|
39
|
+
fullName: info.full_name
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Download repo tarball and return the buffer.
|
|
45
|
+
*/
|
|
46
|
+
export async function downloadTarball(repo, ref = 'HEAD') {
|
|
47
|
+
const url = `${GITHUB_API}/repos/${repo}/tarball/${ref}`;
|
|
48
|
+
const res = await fetch(url, {
|
|
49
|
+
headers: ghHeaders(),
|
|
50
|
+
redirect: 'follow'
|
|
51
|
+
});
|
|
52
|
+
if (!res.ok) {
|
|
53
|
+
throw new Error(`Failed to download tarball: ${res.status} ${res.statusText}`);
|
|
54
|
+
}
|
|
55
|
+
return Buffer.from(await res.arrayBuffer());
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Get latest commit SHA for a ref.
|
|
60
|
+
*/
|
|
61
|
+
export async function getLatestSha(repo, ref = 'HEAD') {
|
|
62
|
+
const res = await fetch(`${GITHUB_API}/repos/${repo}/commits/${ref}`, {
|
|
63
|
+
headers: ghHeaders()
|
|
64
|
+
});
|
|
65
|
+
if (!res.ok) return null;
|
|
66
|
+
const data = await res.json();
|
|
67
|
+
return data.sha;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function ghHeaders() {
|
|
71
|
+
const headers = {
|
|
72
|
+
'Accept': 'application/vnd.github.v3+json',
|
|
73
|
+
'User-Agent': 'bananahub-cli/0.1.0'
|
|
74
|
+
};
|
|
75
|
+
if (process.env.GITHUB_TOKEN) {
|
|
76
|
+
headers['Authorization'] = `token ${process.env.GITHUB_TOKEN}`;
|
|
77
|
+
}
|
|
78
|
+
return headers;
|
|
79
|
+
}
|
package/lib/hub.js
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { CLI_VERSION, HUB_API, HUB_CATALOG_URL } from './constants.js';
|
|
2
|
+
|
|
3
|
+
export async function fetchHubCatalog() {
|
|
4
|
+
const res = await fetch(HUB_CATALOG_URL, {
|
|
5
|
+
headers: hubHeaders()
|
|
6
|
+
});
|
|
7
|
+
|
|
8
|
+
if (!res.ok) {
|
|
9
|
+
throw new Error(`Failed to fetch hub catalog: ${res.status} ${res.statusText}`);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const data = await res.json();
|
|
13
|
+
if (!data || !Array.isArray(data.templates)) {
|
|
14
|
+
throw new Error('Hub catalog is missing the templates array.');
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
return data;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function fetchHubTrending({ period = '7d', limit = 10 } = {}) {
|
|
21
|
+
const url = new URL(`${HUB_API}/trending`);
|
|
22
|
+
url.searchParams.set('period', period);
|
|
23
|
+
url.searchParams.set('limit', String(limit));
|
|
24
|
+
|
|
25
|
+
const res = await fetch(url, {
|
|
26
|
+
headers: hubHeaders()
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
if (!res.ok) {
|
|
30
|
+
throw new Error(`Failed to fetch hub trending data: ${res.status} ${res.statusText}`);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const data = await res.json();
|
|
34
|
+
if (!data || !Array.isArray(data.templates)) {
|
|
35
|
+
throw new Error('Hub trending response is missing the templates array.');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return data;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function buildCatalogLookup(catalog) {
|
|
42
|
+
const lookup = new Map();
|
|
43
|
+
for (const template of catalog.templates || []) {
|
|
44
|
+
lookup.set(templateKey(template.repo, template.id), template);
|
|
45
|
+
}
|
|
46
|
+
return lookup;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function compareCatalogPriority(left, right) {
|
|
50
|
+
const pinnedDiff = getPinnedRank(left) - getPinnedRank(right);
|
|
51
|
+
if (pinnedDiff !== 0) {
|
|
52
|
+
return pinnedDiff;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const featuredDiff = toFlag(right?.featured) - toFlag(left?.featured);
|
|
56
|
+
if (featuredDiff !== 0) {
|
|
57
|
+
return featuredDiff;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const sourceDiff = sourceRank(right?.catalog_source) - sourceRank(left?.catalog_source);
|
|
61
|
+
if (sourceDiff !== 0) {
|
|
62
|
+
return sourceDiff;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const officialDiff = toFlag(right?.official) - toFlag(left?.official);
|
|
66
|
+
if (officialDiff !== 0) {
|
|
67
|
+
return officialDiff;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const updatedDiff = String(right?.updated || '').localeCompare(String(left?.updated || ''));
|
|
71
|
+
if (updatedDiff !== 0) {
|
|
72
|
+
return updatedDiff;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return String(left?.id || '').localeCompare(String(right?.id || ''));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function templateKey(repo, templateId) {
|
|
79
|
+
return `${repo || ''}::${templateId || ''}`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function hubHeaders() {
|
|
83
|
+
return {
|
|
84
|
+
Accept: 'application/json',
|
|
85
|
+
'User-Agent': `bananahub-cli/${CLI_VERSION}`
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function getPinnedRank(template) {
|
|
90
|
+
return Number.isFinite(template?.pinned_rank)
|
|
91
|
+
? template.pinned_rank
|
|
92
|
+
: Number.POSITIVE_INFINITY;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function toFlag(value) {
|
|
96
|
+
return value ? 1 : 0;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function sourceRank(source) {
|
|
100
|
+
if (source === 'curated') return 2;
|
|
101
|
+
if (source === 'discovered') return 1;
|
|
102
|
+
return 0;
|
|
103
|
+
}
|
package/lib/registry.js
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { readdir, readFile, writeFile, mkdir } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { TEMPLATES_DIR, REGISTRY_FILE } from './constants.js';
|
|
4
|
+
import { parseFrontmatter } from './frontmatter.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Rebuild .registry.json by scanning all installed template directories.
|
|
8
|
+
*/
|
|
9
|
+
export async function rebuildRegistry() {
|
|
10
|
+
await mkdir(TEMPLATES_DIR, { recursive: true });
|
|
11
|
+
const entries = await readdir(TEMPLATES_DIR, { withFileTypes: true });
|
|
12
|
+
const templates = [];
|
|
13
|
+
|
|
14
|
+
for (const entry of entries) {
|
|
15
|
+
if (!entry.isDirectory() || entry.name.startsWith('.')) continue;
|
|
16
|
+
const tmplPath = join(TEMPLATES_DIR, entry.name, 'template.md');
|
|
17
|
+
try {
|
|
18
|
+
const content = await readFile(tmplPath, 'utf8');
|
|
19
|
+
const fm = parseFrontmatter(content);
|
|
20
|
+
if (!fm) continue;
|
|
21
|
+
|
|
22
|
+
let source = null;
|
|
23
|
+
try {
|
|
24
|
+
const srcJson = await readFile(join(TEMPLATES_DIR, entry.name, '.source.json'), 'utf8');
|
|
25
|
+
source = JSON.parse(srcJson);
|
|
26
|
+
} catch { /* no source info */ }
|
|
27
|
+
|
|
28
|
+
templates.push({
|
|
29
|
+
id: fm.id || entry.name,
|
|
30
|
+
type: fm.type || 'prompt',
|
|
31
|
+
title: fm.title || '',
|
|
32
|
+
title_en: fm.title_en || '',
|
|
33
|
+
author: fm.author || '',
|
|
34
|
+
profile: fm.profile || 'general',
|
|
35
|
+
tags: fm.tags || [],
|
|
36
|
+
difficulty: fm.difficulty || 'beginner',
|
|
37
|
+
aspect: fm.aspect || '',
|
|
38
|
+
models: Array.isArray(fm.models) ? fm.models.map(m => m.name || m) : [],
|
|
39
|
+
source: source?.repo || '',
|
|
40
|
+
version: fm.version || '0.0.0',
|
|
41
|
+
installed_at: source?.installed_at || ''
|
|
42
|
+
});
|
|
43
|
+
} catch {
|
|
44
|
+
// skip unreadable templates
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const registry = {
|
|
49
|
+
version: '1.0.0',
|
|
50
|
+
generated_at: new Date().toISOString(),
|
|
51
|
+
templates
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
await writeFile(join(TEMPLATES_DIR, REGISTRY_FILE), JSON.stringify(registry, null, 2));
|
|
55
|
+
return registry;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Load the current registry (or rebuild if missing).
|
|
60
|
+
*/
|
|
61
|
+
export async function loadRegistry() {
|
|
62
|
+
try {
|
|
63
|
+
const raw = await readFile(join(TEMPLATES_DIR, REGISTRY_FILE), 'utf8');
|
|
64
|
+
return JSON.parse(raw);
|
|
65
|
+
} catch {
|
|
66
|
+
return rebuildRegistry();
|
|
67
|
+
}
|
|
68
|
+
}
|
package/lib/validate.js
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import { readFile, access, readdir } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { parseFrontmatter } from './frontmatter.js';
|
|
4
|
+
import { VALID_PROFILES, VALID_DIFFICULTIES, VALID_TEMPLATE_TYPES } from './constants.js';
|
|
5
|
+
|
|
6
|
+
const SAMPLE_FILE_PATTERN = /^sample-[a-z0-9.]+(?:-[a-z0-9.]+)*-\d{2}\.(jpg|jpeg|png|webp)$/i;
|
|
7
|
+
const DEFAULT_TEMPLATE_TYPE = 'prompt';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Validate a template directory. Returns { valid, errors, warnings, meta }.
|
|
11
|
+
*/
|
|
12
|
+
export async function validateTemplate(dirPath) {
|
|
13
|
+
const errors = [];
|
|
14
|
+
const warnings = [];
|
|
15
|
+
let meta = null;
|
|
16
|
+
|
|
17
|
+
const tmplPath = join(dirPath, 'template.md');
|
|
18
|
+
let content;
|
|
19
|
+
try {
|
|
20
|
+
content = await readFile(tmplPath, 'utf8');
|
|
21
|
+
} catch {
|
|
22
|
+
return { valid: false, errors: ['template.md not found'], warnings, meta };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const fm = parseFrontmatter(content);
|
|
26
|
+
if (!fm) {
|
|
27
|
+
return { valid: false, errors: ['No YAML frontmatter found (missing --- delimiters)'], warnings, meta };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const templateType = fm.type || DEFAULT_TEMPLATE_TYPE;
|
|
31
|
+
meta = { ...fm, type: templateType };
|
|
32
|
+
|
|
33
|
+
const requiredFields = ['title', 'profile'];
|
|
34
|
+
for (const field of requiredFields) {
|
|
35
|
+
if (!fm[field]) {
|
|
36
|
+
errors.push(`Missing required field: ${field}`);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (fm.type && !VALID_TEMPLATE_TYPES.includes(fm.type)) {
|
|
41
|
+
errors.push(`Invalid type "${fm.type}". Must be one of: ${VALID_TEMPLATE_TYPES.join(', ')}`);
|
|
42
|
+
} else if (!fm.type) {
|
|
43
|
+
warnings.push('No type defined — defaulting to `prompt`. Add `type: prompt` or `type: workflow` for clarity');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (fm.id && !/^[a-z][a-z0-9-]{1,48}[a-z0-9]$/.test(fm.id)) {
|
|
47
|
+
warnings.push(`ID "${fm.id}" should be lowercase, hyphens only, 3-50 chars`);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (fm.profile && !VALID_PROFILES.includes(fm.profile)) {
|
|
51
|
+
errors.push(`Invalid profile "${fm.profile}". Must be one of: ${VALID_PROFILES.join(', ')}`);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (fm.difficulty && !VALID_DIFFICULTIES.includes(fm.difficulty)) {
|
|
55
|
+
warnings.push(`Invalid difficulty "${fm.difficulty}". Should be: ${VALID_DIFFICULTIES.join(', ')}`);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (!fm.tags || !Array.isArray(fm.tags) || fm.tags.length === 0) {
|
|
59
|
+
warnings.push('No tags defined — templates are harder to discover without tags');
|
|
60
|
+
} else if (fm.tags.length < 3) {
|
|
61
|
+
warnings.push(`Only ${fm.tags.length} tags — recommend at least 3 for better discoverability`);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (fm.version && !/^\d+\.\d+\.\d+/.test(fm.version)) {
|
|
65
|
+
warnings.push(`Version "${fm.version}" is not valid semver`);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (!fm.models || !Array.isArray(fm.models) || fm.models.length === 0) {
|
|
69
|
+
warnings.push('No models listed — users won\'t know which models are tested');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
validateBody(content, templateType, warnings);
|
|
73
|
+
await validateSamplesDir(dirPath, templateType, warnings);
|
|
74
|
+
await validateSampleMetadata(dirPath, meta, warnings);
|
|
75
|
+
await validateReadme(dirPath, meta, warnings);
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
valid: errors.length === 0,
|
|
79
|
+
errors,
|
|
80
|
+
warnings,
|
|
81
|
+
meta
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function validateBody(content, templateType, warnings) {
|
|
86
|
+
if (templateType === 'workflow') {
|
|
87
|
+
const requiredSections = ['## Goal', '## Inputs', '## Steps', '## Prompt Blocks'];
|
|
88
|
+
for (const section of requiredSections) {
|
|
89
|
+
if (!content.includes(section)) {
|
|
90
|
+
warnings.push(`Workflow template should include a "${section}" section`);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
if (!/^\d+\.\s+/m.test(content)) {
|
|
94
|
+
warnings.push('Workflow template should include a numbered step list under "## Steps"');
|
|
95
|
+
}
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (!content.includes('## Prompt Template') && !content.includes('## prompt template')) {
|
|
100
|
+
warnings.push('No "## Prompt Template" section found');
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const varMatches = content.match(/\{\{(\w+)(?:\|[^}]*)?\}\}/g);
|
|
104
|
+
if (!varMatches || varMatches.length === 0) {
|
|
105
|
+
warnings.push('No template variables ({{var|default}}) found — template is static');
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function validateSamplesDir(dirPath, templateType, warnings) {
|
|
110
|
+
try {
|
|
111
|
+
const samplesDir = join(dirPath, 'samples');
|
|
112
|
+
await access(samplesDir);
|
|
113
|
+
const sampleFiles = await readdir(samplesDir);
|
|
114
|
+
const imageFiles = sampleFiles.filter(f => /\.(jpg|jpeg|png|webp)$/i.test(f));
|
|
115
|
+
if (imageFiles.length === 0 && templateType === 'prompt') {
|
|
116
|
+
warnings.push('samples/ directory exists but contains no images');
|
|
117
|
+
}
|
|
118
|
+
} catch {
|
|
119
|
+
if (templateType === 'prompt') {
|
|
120
|
+
warnings.push('No samples/ directory — sample images help users preview results');
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function validateSampleMetadata(dirPath, fm, warnings) {
|
|
126
|
+
if (!fm.samples || !Array.isArray(fm.samples) || fm.samples.length === 0) {
|
|
127
|
+
if (fm.type === 'prompt') {
|
|
128
|
+
warnings.push('No sample metadata in frontmatter — add `samples` entries with file/model/prompt/aspect');
|
|
129
|
+
}
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
for (const sample of fm.samples) {
|
|
134
|
+
if (!sample || typeof sample !== 'object') {
|
|
135
|
+
warnings.push('Invalid sample metadata entry — each `samples` item should be an object');
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (!sample.file) {
|
|
140
|
+
warnings.push('A sample entry is missing `file`');
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const fileName = sample.file.split('/').pop();
|
|
145
|
+
if (!SAMPLE_FILE_PATTERN.test(fileName)) {
|
|
146
|
+
warnings.push(`Sample file "${sample.file}" should follow sample-{model-short}-{nn}.ext naming`);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (!sample.model) {
|
|
150
|
+
warnings.push(`Sample "${sample.file}" is missing \`model\``);
|
|
151
|
+
} else if (!fileNameIncludesModel(fileName, sample.model)) {
|
|
152
|
+
warnings.push(`Sample file "${sample.file}" should include the generating model shorthand for "${sample.model}"`);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (!sample.prompt) {
|
|
156
|
+
warnings.push(`Sample "${sample.file}" is missing \`prompt\``);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (!sample.aspect) {
|
|
160
|
+
warnings.push(`Sample "${sample.file}" is missing \`aspect\``);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
try {
|
|
164
|
+
await access(join(dirPath, sample.file));
|
|
165
|
+
} catch {
|
|
166
|
+
warnings.push(`Sample file not found on disk: ${sample.file}`);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async function validateReadme(dirPath, fm, warnings) {
|
|
172
|
+
let readme;
|
|
173
|
+
try {
|
|
174
|
+
readme = await readFile(join(dirPath, 'README.md'), 'utf8');
|
|
175
|
+
} catch {
|
|
176
|
+
warnings.push('No README.md — published templates should document install, verified models, supported models, and sample mappings');
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (!hasSection(readme, ['Verified Models', '验证模型', '已验证模型'])) {
|
|
181
|
+
warnings.push('README.md should include a "Verified Models" section');
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (!hasSection(readme, ['Supported Models', '支持模型', '兼容模型'])) {
|
|
185
|
+
warnings.push('README.md should include a "Supported Models" section');
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (!hasSection(readme, ['Sample Outputs', 'Sample Output', 'Samples', '样图', '示例输出'])) {
|
|
189
|
+
warnings.push('README.md should include a sample mapping section that ties image files to models and prompts');
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (Array.isArray(fm.samples)) {
|
|
193
|
+
for (const sample of fm.samples) {
|
|
194
|
+
if (!sample || typeof sample !== 'object' || !sample.file) {
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const fileName = sample.file.split('/').pop();
|
|
199
|
+
if (!readme.includes(fileName)) {
|
|
200
|
+
warnings.push(`README.md should reference sample file "${fileName}" in its sample mapping`);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (sample.model && !readme.includes(sample.model)) {
|
|
204
|
+
warnings.push(`README.md should mention sample model "${sample.model}"`);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function hasSection(content, names) {
|
|
211
|
+
return names.some((name) => {
|
|
212
|
+
const pattern = new RegExp(`^##\\s+${escapeRegExp(name)}\\s*$`, 'im');
|
|
213
|
+
return pattern.test(content);
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function fileNameIncludesModel(fileName, modelName) {
|
|
218
|
+
const normalizedFileName = String(fileName || '').toLowerCase();
|
|
219
|
+
const normalizedModel = String(modelName || '').toLowerCase();
|
|
220
|
+
const versionTierMatch = normalizedModel.match(/gemini-(\d+(?:\.\d+)?)-(pro|flash)/);
|
|
221
|
+
|
|
222
|
+
if (normalizedFileName.includes(normalizedModel)) {
|
|
223
|
+
return true;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if (versionTierMatch) {
|
|
227
|
+
const shorthand = `${versionTierMatch[1]}-${versionTierMatch[2]}`;
|
|
228
|
+
return normalizedFileName.includes(shorthand);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return false;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function escapeRegExp(value) {
|
|
235
|
+
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
236
|
+
}
|