@whittakertech/virgil 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/.tool-versions +1 -0
- package/README.md +230 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +39 -0
- package/dist/cli.js.map +1 -0
- package/dist/engine/hash.d.ts +20 -0
- package/dist/engine/hash.d.ts.map +1 -0
- package/dist/engine/hash.js +53 -0
- package/dist/engine/hash.js.map +1 -0
- package/dist/engine/lock.d.ts +18 -0
- package/dist/engine/lock.d.ts.map +1 -0
- package/dist/engine/lock.js +54 -0
- package/dist/engine/lock.js.map +1 -0
- package/dist/engine/manifest.d.ts +14 -0
- package/dist/engine/manifest.d.ts.map +1 -0
- package/dist/engine/manifest.js +39 -0
- package/dist/engine/manifest.js.map +1 -0
- package/dist/engine/run.d.ts +15 -0
- package/dist/engine/run.d.ts.map +1 -0
- package/dist/engine/run.js +110 -0
- package/dist/engine/run.js.map +1 -0
- package/dist/generators/og-image.d.ts +16 -0
- package/dist/generators/og-image.d.ts.map +1 -0
- package/dist/generators/og-image.js +46 -0
- package/dist/generators/og-image.js.map +1 -0
- package/dist/generators/robots.d.ts +9 -0
- package/dist/generators/robots.d.ts.map +1 -0
- package/dist/generators/robots.js +29 -0
- package/dist/generators/robots.js.map +1 -0
- package/dist/generators/sitemap.d.ts +9 -0
- package/dist/generators/sitemap.d.ts.map +1 -0
- package/dist/generators/sitemap.js +24 -0
- package/dist/generators/sitemap.js.map +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -0
- package/dist/templates/og-default.html +100 -0
- package/dist/types/lock.d.ts +11 -0
- package/dist/types/lock.d.ts.map +1 -0
- package/dist/types/lock.js +2 -0
- package/dist/types/lock.js.map +1 -0
- package/dist/types/manifest.d.ts +7 -0
- package/dist/types/manifest.d.ts.map +1 -0
- package/dist/types/manifest.js +2 -0
- package/dist/types/manifest.js.map +1 -0
- package/dist/types/spec.d.ts +47 -0
- package/dist/types/spec.d.ts.map +1 -0
- package/dist/types/spec.js +2 -0
- package/dist/types/spec.js.map +1 -0
- package/dist/utils/fs.d.ts +13 -0
- package/dist/utils/fs.d.ts.map +1 -0
- package/dist/utils/fs.js +32 -0
- package/dist/utils/fs.js.map +1 -0
- package/dist/utils/paths.d.ts +14 -0
- package/dist/utils/paths.d.ts.map +1 -0
- package/dist/utils/paths.js +30 -0
- package/dist/utils/paths.js.map +1 -0
- package/package.json +37 -0
- package/src/cli.ts +47 -0
- package/src/engine/hash.ts +71 -0
- package/src/engine/lock.ts +72 -0
- package/src/engine/manifest.ts +52 -0
- package/src/engine/run.ts +156 -0
- package/src/generators/og-image.ts +65 -0
- package/src/generators/robots.ts +44 -0
- package/src/generators/sitemap.ts +36 -0
- package/src/index.ts +8 -0
- package/src/templates/og-default.html +100 -0
- package/src/types/lock.ts +11 -0
- package/src/types/manifest.ts +6 -0
- package/src/types/spec.ts +52 -0
- package/src/utils/fs.ts +32 -0
- package/src/utils/paths.ts +33 -0
- package/tsconfig.json +20 -0
- package/virgil.spec.example.json +65 -0
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { readFile, writeFile } from 'fs/promises';
|
|
2
|
+
import { VirgilLock, LockEntry } from '../types/lock.js';
|
|
3
|
+
|
|
4
|
+
const LOCK_VERSION = '0.1' as const;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Load lock file if it exists, return empty lock otherwise
|
|
8
|
+
*/
|
|
9
|
+
export async function loadLock(lockPath: string): Promise<VirgilLock> {
|
|
10
|
+
try {
|
|
11
|
+
const contents = await readFile(lockPath, 'utf-8');
|
|
12
|
+
return JSON.parse(contents);
|
|
13
|
+
} catch (err) {
|
|
14
|
+
// Lock doesn't exist or is invalid - start fresh
|
|
15
|
+
return {
|
|
16
|
+
version: LOCK_VERSION,
|
|
17
|
+
entries: {}
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Write lock file atomically
|
|
24
|
+
*/
|
|
25
|
+
export async function saveLock(lockPath: string, lock: VirgilLock): Promise<void> {
|
|
26
|
+
const contents = JSON.stringify(lock, null, 2);
|
|
27
|
+
|
|
28
|
+
// Write to temp file, then rename (atomic on POSIX)
|
|
29
|
+
const tempPath = `${lockPath}.tmp`;
|
|
30
|
+
await writeFile(tempPath, contents, 'utf-8');
|
|
31
|
+
|
|
32
|
+
// Atomic rename
|
|
33
|
+
const { rename } = await import('fs/promises');
|
|
34
|
+
await rename(tempPath, lockPath);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Check if output needs regeneration
|
|
39
|
+
*/
|
|
40
|
+
export function needsRegeneration(
|
|
41
|
+
lock: VirgilLock,
|
|
42
|
+
id: string,
|
|
43
|
+
currentHash: string
|
|
44
|
+
): boolean {
|
|
45
|
+
const entry = lock.entries[id];
|
|
46
|
+
|
|
47
|
+
if (!entry) {
|
|
48
|
+
// Never generated before
|
|
49
|
+
return true;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Compare hashes
|
|
53
|
+
return entry.hash !== currentHash;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Update lock entry
|
|
58
|
+
*/
|
|
59
|
+
export function updateLockEntry(
|
|
60
|
+
lock: VirgilLock,
|
|
61
|
+
id: string,
|
|
62
|
+
hash: string,
|
|
63
|
+
generator: string,
|
|
64
|
+
generatorVersion: string
|
|
65
|
+
): void {
|
|
66
|
+
lock.entries[id] = {
|
|
67
|
+
hash,
|
|
68
|
+
generatedAt: new Date().toISOString(),
|
|
69
|
+
generator,
|
|
70
|
+
generatorVersion
|
|
71
|
+
};
|
|
72
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { readFile, writeFile } from 'fs/promises';
|
|
2
|
+
import { VirgilManifest } from '../types/manifest.js';
|
|
3
|
+
|
|
4
|
+
const MANIFEST_VERSION = '0.1' as const;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Load manifest if it exists, return empty manifest otherwise
|
|
8
|
+
*/
|
|
9
|
+
export async function loadManifest(manifestPath: string): Promise<VirgilManifest> {
|
|
10
|
+
try {
|
|
11
|
+
const contents = await readFile(manifestPath, 'utf-8');
|
|
12
|
+
return JSON.parse(contents);
|
|
13
|
+
} catch (err) {
|
|
14
|
+
return {
|
|
15
|
+
version: MANIFEST_VERSION,
|
|
16
|
+
og: {},
|
|
17
|
+
sitemap: {},
|
|
18
|
+
robots: {}
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Write manifest atomically
|
|
25
|
+
*/
|
|
26
|
+
export async function saveManifest(
|
|
27
|
+
manifestPath: string,
|
|
28
|
+
manifest: VirgilManifest
|
|
29
|
+
): Promise<void> {
|
|
30
|
+
const contents = JSON.stringify(manifest, null, 2);
|
|
31
|
+
|
|
32
|
+
const tempPath = `${manifestPath}.tmp`;
|
|
33
|
+
await writeFile(tempPath, contents, 'utf-8');
|
|
34
|
+
|
|
35
|
+
const { rename } = await import('fs/promises');
|
|
36
|
+
await rename(tempPath, manifestPath);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Update manifest entry for given output type
|
|
41
|
+
*/
|
|
42
|
+
export function updateManifestEntry(
|
|
43
|
+
manifest: VirgilManifest,
|
|
44
|
+
type: 'og' | 'sitemap' | 'robots',
|
|
45
|
+
id: string,
|
|
46
|
+
path: string
|
|
47
|
+
): void {
|
|
48
|
+
if (!manifest[type]) {
|
|
49
|
+
manifest[type] = {};
|
|
50
|
+
}
|
|
51
|
+
manifest[type]![id] = path;
|
|
52
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { readFile } from 'fs/promises';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
import { VirgilSpec, OutputConfig } from '../types/spec.js';
|
|
4
|
+
import { VirgilLock } from '../types/lock.js';
|
|
5
|
+
import { VirgilManifest } from '../types/manifest.js';
|
|
6
|
+
import { PathResolver } from '../utils/paths.js';
|
|
7
|
+
import { computeHash, GENERATOR_VERSION } from './hash.js';
|
|
8
|
+
import { loadLock, saveLock, needsRegeneration, updateLockEntry } from './lock.js';
|
|
9
|
+
import { loadManifest, saveManifest, updateManifestEntry } from './manifest.js';
|
|
10
|
+
import { generateOGImage, generateOGImageFilename } from '../generators/og-image.js';
|
|
11
|
+
import { generateSitemap } from '../generators/sitemap.js';
|
|
12
|
+
import { generateRobots } from '../generators/robots.js';
|
|
13
|
+
|
|
14
|
+
export interface RunOptions {
|
|
15
|
+
rootDir: string;
|
|
16
|
+
outputDir: string;
|
|
17
|
+
verbose?: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface RunResult {
|
|
21
|
+
generated: number;
|
|
22
|
+
skipped: number;
|
|
23
|
+
errors: string[];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Main orchestration - this is the heart of Virgil
|
|
28
|
+
*/
|
|
29
|
+
export async function run(options: RunOptions): Promise<RunResult> {
|
|
30
|
+
const paths = new PathResolver(options.rootDir);
|
|
31
|
+
const result: RunResult = {
|
|
32
|
+
generated: 0,
|
|
33
|
+
skipped: 0,
|
|
34
|
+
errors: []
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
try {
|
|
38
|
+
// Step 1: Load spec
|
|
39
|
+
const specContents = await readFile(paths.spec, 'utf-8');
|
|
40
|
+
const spec: VirgilSpec = JSON.parse(specContents);
|
|
41
|
+
|
|
42
|
+
if (options.verbose) {
|
|
43
|
+
console.log(`📋 Loaded spec with ${spec.outputs.length} outputs`);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Step 2: Load previous lock
|
|
47
|
+
const lock = await loadLock(paths.lock);
|
|
48
|
+
|
|
49
|
+
// Step 3: Load manifest
|
|
50
|
+
const manifest = await loadManifest(paths.manifest);
|
|
51
|
+
|
|
52
|
+
// Step 4-7: Process each output
|
|
53
|
+
for (const output of spec.outputs) {
|
|
54
|
+
try {
|
|
55
|
+
await processOutput(spec, output, lock, manifest, options, result);
|
|
56
|
+
} catch (err) {
|
|
57
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
58
|
+
result.errors.push(`${output.id}: ${message}`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Step 8: Write updated lock
|
|
63
|
+
await saveLock(paths.lock, lock);
|
|
64
|
+
|
|
65
|
+
// Step 9: Write updated manifest
|
|
66
|
+
await saveManifest(paths.manifest, manifest);
|
|
67
|
+
|
|
68
|
+
if (options.verbose) {
|
|
69
|
+
console.log(`✅ Generated: ${result.generated}, Skipped: ${result.skipped}, Errors: ${result.errors.length}`);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return result;
|
|
73
|
+
|
|
74
|
+
} catch (err) {
|
|
75
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
76
|
+
result.errors.push(`Fatal: ${message}`);
|
|
77
|
+
return result;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function processOutput(
|
|
82
|
+
spec: VirgilSpec,
|
|
83
|
+
output: OutputConfig,
|
|
84
|
+
lock: VirgilLock,
|
|
85
|
+
manifest: VirgilManifest,
|
|
86
|
+
options: RunOptions,
|
|
87
|
+
result: RunResult
|
|
88
|
+
): Promise<void> {
|
|
89
|
+
// Compute current hash
|
|
90
|
+
const hash = await computeHash({
|
|
91
|
+
data: output,
|
|
92
|
+
generatorName: output.type
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
// Check if regeneration needed
|
|
96
|
+
if (!needsRegeneration(lock, output.id, hash)) {
|
|
97
|
+
if (options.verbose) {
|
|
98
|
+
console.log(`⏭️ Skipping ${output.id} (unchanged)`);
|
|
99
|
+
}
|
|
100
|
+
result.skipped++;
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (options.verbose) {
|
|
105
|
+
console.log(`🔨 Generating ${output.id}`);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Generate based on type
|
|
109
|
+
let outputPath: string;
|
|
110
|
+
let manifestPath: string;
|
|
111
|
+
|
|
112
|
+
switch (output.type) {
|
|
113
|
+
case 'og-image': {
|
|
114
|
+
const filename = generateOGImageFilename(output.id);
|
|
115
|
+
outputPath = join(options.outputDir, 'og', filename);
|
|
116
|
+
manifestPath = `/og/${filename}`;
|
|
117
|
+
|
|
118
|
+
await generateOGImage(
|
|
119
|
+
{
|
|
120
|
+
brand: spec.brand,
|
|
121
|
+
product: spec.product,
|
|
122
|
+
output
|
|
123
|
+
},
|
|
124
|
+
outputPath
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
updateManifestEntry(manifest, 'og', output.id, manifestPath);
|
|
128
|
+
break;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
case 'sitemap': {
|
|
132
|
+
outputPath = join(options.outputDir, 'sitemap.xml');
|
|
133
|
+
manifestPath = '/sitemap.xml';
|
|
134
|
+
|
|
135
|
+
await generateSitemap({ output }, outputPath);
|
|
136
|
+
updateManifestEntry(manifest, 'sitemap', output.id, manifestPath);
|
|
137
|
+
break;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
case 'robots': {
|
|
141
|
+
outputPath = join(options.outputDir, 'robots.txt');
|
|
142
|
+
manifestPath = '/robots.txt';
|
|
143
|
+
|
|
144
|
+
await generateRobots({ output }, outputPath);
|
|
145
|
+
updateManifestEntry(manifest, 'robots', output.id, manifestPath);
|
|
146
|
+
break;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
default:
|
|
150
|
+
throw new Error(`Unknown output type: ${(output as any).type}`);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Update lock
|
|
154
|
+
updateLockEntry(lock, output.id, hash, output.type, GENERATOR_VERSION);
|
|
155
|
+
result.generated++;
|
|
156
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { chromium } from 'playwright';
|
|
2
|
+
import { readFile, writeFile } from 'fs/promises';
|
|
3
|
+
import { join, dirname } from 'path';
|
|
4
|
+
import { fileURLToPath } from 'url';
|
|
5
|
+
import { OGImageOutput, BrandConfig, ProductConfig } from '../types/spec.js';
|
|
6
|
+
import { ensureParentDir } from '../utils/fs.js';
|
|
7
|
+
|
|
8
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
9
|
+
|
|
10
|
+
export interface OGImageContext {
|
|
11
|
+
brand: BrandConfig;
|
|
12
|
+
product: ProductConfig;
|
|
13
|
+
output: OGImageOutput;
|
|
14
|
+
templatePath?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Generate OG image using Playwright
|
|
19
|
+
*/
|
|
20
|
+
export async function generateOGImage(
|
|
21
|
+
ctx: OGImageContext,
|
|
22
|
+
outputPath: string
|
|
23
|
+
): Promise<void> {
|
|
24
|
+
// Load template
|
|
25
|
+
const templatePath = ctx.templatePath || join(__dirname, '../templates/og-default.html');
|
|
26
|
+
let html = await readFile(templatePath, 'utf-8');
|
|
27
|
+
|
|
28
|
+
// Replace placeholders
|
|
29
|
+
html = html
|
|
30
|
+
.replace(/{{brand.name}}/g, ctx.brand.name)
|
|
31
|
+
.replace(/{{brand.color}}/g, ctx.brand.color)
|
|
32
|
+
.replace(/{{product.name}}/g, ctx.product.name)
|
|
33
|
+
.replace(/{{product.version}}/g, ctx.product.version)
|
|
34
|
+
.replace(/{{page.title}}/g, ctx.output.page.title)
|
|
35
|
+
.replace(/{{page.url}}/g, ctx.output.page.url)
|
|
36
|
+
.replace(/{{page.description}}/g, ctx.output.page.description || '');
|
|
37
|
+
|
|
38
|
+
// Launch headless browser
|
|
39
|
+
const browser = await chromium.launch();
|
|
40
|
+
const page = await browser.newPage({
|
|
41
|
+
viewport: { width: 1200, height: 630 }
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
// Set content and wait for fonts/assets
|
|
45
|
+
await page.setContent(html, { waitUntil: 'networkidle' });
|
|
46
|
+
|
|
47
|
+
// Ensure output directory exists
|
|
48
|
+
await ensureParentDir(outputPath);
|
|
49
|
+
|
|
50
|
+
// Screenshot
|
|
51
|
+
await page.screenshot({
|
|
52
|
+
path: outputPath,
|
|
53
|
+
type: 'png'
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
await browser.close();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Generate filename for OG image with cache-busting timestamp
|
|
61
|
+
*/
|
|
62
|
+
export function generateOGImageFilename(id: string): string {
|
|
63
|
+
const timestamp = Math.floor(Date.now() / 1000);
|
|
64
|
+
return `${id}.${timestamp}.png`;
|
|
65
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { writeFile } from 'fs/promises';
|
|
2
|
+
import { RobotsOutput } from '../types/spec.js';
|
|
3
|
+
import { ensureParentDir } from '../utils/fs.js';
|
|
4
|
+
|
|
5
|
+
export interface RobotsContext {
|
|
6
|
+
output: RobotsOutput;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Generate robots.txt
|
|
11
|
+
*/
|
|
12
|
+
export async function generateRobots(
|
|
13
|
+
ctx: RobotsContext,
|
|
14
|
+
outputPath: string
|
|
15
|
+
): Promise<void> {
|
|
16
|
+
const lines: string[] = [];
|
|
17
|
+
|
|
18
|
+
for (const rule of ctx.output.rules) {
|
|
19
|
+
lines.push(`User-agent: ${rule.userAgent}`);
|
|
20
|
+
|
|
21
|
+
if (rule.allow) {
|
|
22
|
+
for (const path of rule.allow) {
|
|
23
|
+
lines.push(`Allow: ${path}`);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (rule.disallow) {
|
|
28
|
+
for (const path of rule.disallow) {
|
|
29
|
+
lines.push(`Disallow: ${path}`);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
lines.push(''); // Blank line between rules
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (ctx.output.sitemap) {
|
|
37
|
+
lines.push(`Sitemap: ${ctx.output.sitemap}`);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const content = lines.join('\n');
|
|
41
|
+
|
|
42
|
+
await ensureParentDir(outputPath);
|
|
43
|
+
await writeFile(outputPath, content, 'utf-8');
|
|
44
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { writeFile } from 'fs/promises';
|
|
2
|
+
import { SitemapOutput } from '../types/spec.js';
|
|
3
|
+
import { ensureParentDir } from '../utils/fs.js';
|
|
4
|
+
|
|
5
|
+
export interface SitemapContext {
|
|
6
|
+
output: SitemapOutput;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Generate sitemap.xml
|
|
11
|
+
*/
|
|
12
|
+
export async function generateSitemap(
|
|
13
|
+
ctx: SitemapContext,
|
|
14
|
+
outputPath: string
|
|
15
|
+
): Promise<void> {
|
|
16
|
+
const { baseUrl, pages } = ctx.output;
|
|
17
|
+
|
|
18
|
+
const urls = pages.map(page => {
|
|
19
|
+
const loc = `${baseUrl}${page.path}`;
|
|
20
|
+
const lastmod = page.lastmod ? `<lastmod>${page.lastmod}</lastmod>` : '';
|
|
21
|
+
const changefreq = page.changefreq ? `<changefreq>${page.changefreq}</changefreq>` : '';
|
|
22
|
+
const priority = page.priority !== undefined ? `<priority>${page.priority}</priority>` : '';
|
|
23
|
+
|
|
24
|
+
return ` <url>
|
|
25
|
+
<loc>${loc}</loc>${lastmod}${changefreq}${priority}
|
|
26
|
+
</url>`;
|
|
27
|
+
}).join('\n');
|
|
28
|
+
|
|
29
|
+
const xml = `<?xml version="1.0" encoding="UTF-8"?>
|
|
30
|
+
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
|
31
|
+
${urls}
|
|
32
|
+
</urlset>`;
|
|
33
|
+
|
|
34
|
+
await ensureParentDir(outputPath);
|
|
35
|
+
await writeFile(outputPath, xml, 'utf-8');
|
|
36
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export { run } from './engine/run.js';
|
|
2
|
+
export { computeHash, hashFile } from './engine/hash.js';
|
|
3
|
+
export { loadLock, saveLock } from './engine/lock.js';
|
|
4
|
+
export { loadManifest, saveManifest } from './engine/manifest.js';
|
|
5
|
+
|
|
6
|
+
export type { VirgilSpec, OutputConfig } from './types/spec.js';
|
|
7
|
+
export type { VirgilLock, LockEntry } from './types/lock.js';
|
|
8
|
+
export type { VirgilManifest } from './types/manifest.js';
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html>
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<style>
|
|
6
|
+
* {
|
|
7
|
+
margin: 0;
|
|
8
|
+
padding: 0;
|
|
9
|
+
box-sizing: border-box;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
body {
|
|
13
|
+
width: 1200px;
|
|
14
|
+
height: 630px;
|
|
15
|
+
background: linear-gradient(135deg, {{brand.color}} 0%, #1a1a2e 100%);
|
|
16
|
+
display: flex;
|
|
17
|
+
align-items: center;
|
|
18
|
+
justify-content: center;
|
|
19
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
|
20
|
+
padding: 60px;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
.container {
|
|
24
|
+
width: 100%;
|
|
25
|
+
height: 100%;
|
|
26
|
+
display: flex;
|
|
27
|
+
flex-direction: column;
|
|
28
|
+
justify-content: space-between;
|
|
29
|
+
color: white;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
.header {
|
|
33
|
+
display: flex;
|
|
34
|
+
align-items: center;
|
|
35
|
+
gap: 20px;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
.brand {
|
|
39
|
+
font-size: 24px;
|
|
40
|
+
font-weight: 600;
|
|
41
|
+
opacity: 0.9;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
.content {
|
|
45
|
+
flex: 1;
|
|
46
|
+
display: flex;
|
|
47
|
+
flex-direction: column;
|
|
48
|
+
justify-content: center;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
.title {
|
|
52
|
+
font-size: 72px;
|
|
53
|
+
font-weight: 700;
|
|
54
|
+
line-height: 1.2;
|
|
55
|
+
margin-bottom: 20px;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
.description {
|
|
59
|
+
font-size: 28px;
|
|
60
|
+
opacity: 0.8;
|
|
61
|
+
line-height: 1.4;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
.footer {
|
|
65
|
+
display: flex;
|
|
66
|
+
justify-content: space-between;
|
|
67
|
+
align-items: center;
|
|
68
|
+
font-size: 20px;
|
|
69
|
+
opacity: 0.7;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
.product {
|
|
73
|
+
font-weight: 600;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
.version {
|
|
77
|
+
background: rgba(255, 255, 255, 0.2);
|
|
78
|
+
padding: 4px 12px;
|
|
79
|
+
border-radius: 4px;
|
|
80
|
+
}
|
|
81
|
+
</style>
|
|
82
|
+
</head>
|
|
83
|
+
<body>
|
|
84
|
+
<div class="container">
|
|
85
|
+
<div class="header">
|
|
86
|
+
<div class="brand">{{brand.name}}</div>
|
|
87
|
+
</div>
|
|
88
|
+
|
|
89
|
+
<div class="content">
|
|
90
|
+
<div class="title">{{page.title}}</div>
|
|
91
|
+
<div class="description">{{page.description}}</div>
|
|
92
|
+
</div>
|
|
93
|
+
|
|
94
|
+
<div class="footer">
|
|
95
|
+
<div class="product">{{product.name}}</div>
|
|
96
|
+
<div class="version">v{{product.version}}</div>
|
|
97
|
+
</div>
|
|
98
|
+
</div>
|
|
99
|
+
</body>
|
|
100
|
+
</html>
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
export interface VirgilSpec {
|
|
2
|
+
brand: BrandConfig;
|
|
3
|
+
product: ProductConfig;
|
|
4
|
+
outputs: OutputConfig[];
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export interface BrandConfig {
|
|
8
|
+
name: string;
|
|
9
|
+
logo: string;
|
|
10
|
+
color: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface ProductConfig {
|
|
14
|
+
name: string;
|
|
15
|
+
logo: string;
|
|
16
|
+
version: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export type OutputConfig = OGImageOutput | SitemapOutput | RobotsOutput;
|
|
20
|
+
|
|
21
|
+
export interface OGImageOutput {
|
|
22
|
+
type: 'og-image';
|
|
23
|
+
id: string;
|
|
24
|
+
page: {
|
|
25
|
+
title: string;
|
|
26
|
+
url: string;
|
|
27
|
+
description?: string;
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface SitemapOutput {
|
|
32
|
+
type: 'sitemap';
|
|
33
|
+
id: string;
|
|
34
|
+
baseUrl: string;
|
|
35
|
+
pages: Array<{
|
|
36
|
+
path: string;
|
|
37
|
+
lastmod?: string;
|
|
38
|
+
changefreq?: 'always' | 'hourly' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'never';
|
|
39
|
+
priority?: number;
|
|
40
|
+
}>;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface RobotsOutput {
|
|
44
|
+
type: 'robots';
|
|
45
|
+
id: string;
|
|
46
|
+
rules: Array<{
|
|
47
|
+
userAgent: string;
|
|
48
|
+
allow?: string[];
|
|
49
|
+
disallow?: string[];
|
|
50
|
+
}>;
|
|
51
|
+
sitemap?: string;
|
|
52
|
+
}
|
package/src/utils/fs.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { mkdir, access } from 'fs/promises';
|
|
2
|
+
import { dirname } from 'path';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Ensure directory exists, create if needed
|
|
6
|
+
*/
|
|
7
|
+
export async function ensureDir(dirPath: string): Promise<void> {
|
|
8
|
+
try {
|
|
9
|
+
await access(dirPath);
|
|
10
|
+
} catch {
|
|
11
|
+
await mkdir(dirPath, { recursive: true });
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Ensure parent directory exists for a file path
|
|
17
|
+
*/
|
|
18
|
+
export async function ensureParentDir(filePath: string): Promise<void> {
|
|
19
|
+
await ensureDir(dirname(filePath));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Check if file exists
|
|
24
|
+
*/
|
|
25
|
+
export async function fileExists(filePath: string): Promise<boolean> {
|
|
26
|
+
try {
|
|
27
|
+
await access(filePath);
|
|
28
|
+
return true;
|
|
29
|
+
} catch {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { resolve, join, relative } from 'path';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Resolve paths relative to project root
|
|
5
|
+
*/
|
|
6
|
+
export class PathResolver {
|
|
7
|
+
constructor(private rootDir: string) {}
|
|
8
|
+
|
|
9
|
+
resolve(...segments: string[]): string {
|
|
10
|
+
return resolve(this.rootDir, ...segments);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
join(...segments: string[]): string {
|
|
14
|
+
return join(this.rootDir, ...segments);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
relative(from: string, to: string): string {
|
|
18
|
+
return relative(from, to);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Standard Virgil paths
|
|
22
|
+
get spec(): string {
|
|
23
|
+
return this.resolve('virgil.spec.json');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
get lock(): string {
|
|
27
|
+
return this.resolve('virgil.lock.json');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
get manifest(): string {
|
|
31
|
+
return this.resolve('virgil.manifest.json');
|
|
32
|
+
}
|
|
33
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"lib": ["ES2022"],
|
|
6
|
+
"moduleResolution": "node",
|
|
7
|
+
"outDir": "./dist",
|
|
8
|
+
"rootDir": "./src",
|
|
9
|
+
"strict": true,
|
|
10
|
+
"esModuleInterop": true,
|
|
11
|
+
"skipLibCheck": true,
|
|
12
|
+
"forceConsistentCasingInFileNames": true,
|
|
13
|
+
"resolveJsonModule": true,
|
|
14
|
+
"declaration": true,
|
|
15
|
+
"declarationMap": true,
|
|
16
|
+
"sourceMap": true
|
|
17
|
+
},
|
|
18
|
+
"include": ["src/**/*"],
|
|
19
|
+
"exclude": ["node_modules", "dist"]
|
|
20
|
+
}
|