@gannochenko/staticstripes 0.0.14 → 0.0.16

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.
Files changed (39) hide show
  1. package/dist/cli/ai-music-api-ai/ai-music-api-ai-generation-strategy.d.ts +3 -4
  2. package/dist/cli/ai-music-api-ai/ai-music-api-ai-generation-strategy.d.ts.map +1 -1
  3. package/dist/cli/ai-music-api-ai/ai-music-api-ai-generation-strategy.js +22 -32
  4. package/dist/cli/ai-music-api-ai/ai-music-api-ai-generation-strategy.js.map +1 -1
  5. package/dist/cli/credentials.d.ts +81 -0
  6. package/dist/cli/credentials.d.ts.map +1 -0
  7. package/dist/cli/credentials.js +109 -0
  8. package/dist/cli/credentials.js.map +1 -0
  9. package/dist/cli/instagram/instagram-upload-strategy.d.ts +3 -1
  10. package/dist/cli/instagram/instagram-upload-strategy.d.ts.map +1 -1
  11. package/dist/cli/instagram/instagram-upload-strategy.js +37 -26
  12. package/dist/cli/instagram/instagram-upload-strategy.js.map +1 -1
  13. package/dist/cli/s3/s3-upload-strategy.d.ts +3 -1
  14. package/dist/cli/s3/s3-upload-strategy.d.ts.map +1 -1
  15. package/dist/cli/s3/s3-upload-strategy.js +91 -44
  16. package/dist/cli/s3/s3-upload-strategy.js.map +1 -1
  17. package/dist/cli/youtube/youtube-upload-strategy.d.ts +3 -0
  18. package/dist/cli/youtube/youtube-upload-strategy.d.ts.map +1 -1
  19. package/dist/cli/youtube/youtube-upload-strategy.js +22 -20
  20. package/dist/cli/youtube/youtube-upload-strategy.js.map +1 -1
  21. package/dist/html-project-parser.d.ts +8 -0
  22. package/dist/html-project-parser.d.ts.map +1 -1
  23. package/dist/html-project-parser.js +71 -5
  24. package/dist/html-project-parser.js.map +1 -1
  25. package/dist/project.d.ts +3 -1
  26. package/dist/project.d.ts.map +1 -1
  27. package/dist/project.js +6 -1
  28. package/dist/project.js.map +1 -1
  29. package/dist/type.d.ts +1 -1
  30. package/dist/type.d.ts.map +1 -1
  31. package/package.json +2 -1
  32. package/src/cli/ai-music-api-ai/ai-music-api-ai-generation-strategy.ts +24 -55
  33. package/src/cli/credentials.ts +171 -0
  34. package/src/cli/instagram/instagram-upload-strategy.ts +38 -39
  35. package/src/cli/s3/s3-upload-strategy.ts +102 -57
  36. package/src/cli/youtube/youtube-upload-strategy.ts +22 -23
  37. package/src/html-project-parser.ts +84 -4
  38. package/src/project.ts +5 -0
  39. package/src/type.ts +1 -1
@@ -1,18 +1,9 @@
1
1
  import { UploadStrategy } from '../upload-strategy';
2
2
  import { Project } from '../../project';
3
3
  import { Upload } from '../../type';
4
- import { readFileSync, existsSync } from 'fs';
5
- import { resolve } from 'path';
6
4
  import ejs from 'ejs';
7
5
  import { makeRequest } from '../../lib/net';
8
-
9
- /**
10
- * Instagram credentials format stored in .auth/<upload-name>.json
11
- */
12
- interface InstagramCredentials {
13
- accessToken: string; // Long-lived Instagram Graph API access token
14
- igUserId: string; // Instagram User ID (not the username)
15
- }
6
+ import { CredentialsManager, InstagramCredentials } from '../credentials';
16
7
 
17
8
  /**
18
9
  * Instagram upload strategy implementation
@@ -22,7 +13,7 @@ export class InstagramUploadStrategy implements UploadStrategy {
22
13
  private readonly API_VERSION = 'v21.0';
23
14
  private readonly GRAPH_API_BASE = 'https://graph.instagram.com';
24
15
 
25
- constructor() {}
16
+ constructor(private credentialsManager?: CredentialsManager) {}
26
17
 
27
18
  getTag(): string {
28
19
  return 'instagram';
@@ -47,36 +38,32 @@ export class InstagramUploadStrategy implements UploadStrategy {
47
38
  const { caption, shareToFeed, thumbOffset, coverUrl, videoUrl } =
48
39
  upload.instagram;
49
40
 
50
- // Load credentials from .auth/<upload-name>.json
51
- const authDir = resolve(projectPath, '.auth');
52
- const credentialsPath = resolve(authDir, `${upload.name}.json`);
53
-
54
- if (!existsSync(credentialsPath)) {
55
- throw new Error(
56
- `❌ Error: Instagram credentials not found\n\n` +
57
- `Expected location: ${credentialsPath}\n\n` +
58
- `💡 Run authentication wizard:\n` +
59
- ` staticstripes auth --upload-name ${upload.name}\n\n` +
60
- `📖 Or view detailed setup instructions:\n` +
61
- ` staticstripes auth-help instagram\n`,
62
- );
63
- }
64
-
65
- console.log(`🔐 Loading credentials from: ${credentialsPath}`);
41
+ // Load credentials from local .auth/<upload-name>.json or global ~/.staticstripes/auth/<upload-name>.json
42
+ const manager =
43
+ this.credentialsManager ||
44
+ new CredentialsManager(projectPath, upload.name);
66
45
 
67
46
  let credentials: InstagramCredentials;
68
47
  try {
69
- const credentialsJson = readFileSync(credentialsPath, 'utf-8');
70
- credentials = JSON.parse(credentialsJson);
71
-
72
- if (!credentials.accessToken || !credentials.igUserId) {
73
- throw new Error('Missing accessToken or igUserId');
74
- }
48
+ credentials = manager.load<InstagramCredentials>([
49
+ 'accessToken',
50
+ 'igUserId',
51
+ ]);
75
52
  } catch (error) {
53
+ // Add helpful context about Instagram credentials
54
+ const errorMessage =
55
+ error instanceof Error ? error.message : String(error);
76
56
  throw new Error(
77
- `❌ Error: Failed to parse Instagram credentials from ${credentialsPath}\n` +
78
- `Ensure the file contains valid JSON with accessToken and igUserId.\n` +
79
- `Error: ${error instanceof Error ? error.message : String(error)}`,
57
+ `${errorMessage}\n\n` +
58
+ `💡 Instagram credentials file should contain:\n` +
59
+ `{\n` +
60
+ ` "accessToken": "YOUR_LONG_LIVED_ACCESS_TOKEN",\n` +
61
+ ` "igUserId": "YOUR_INSTAGRAM_USER_ID"\n` +
62
+ `}\n\n` +
63
+ `📖 Run authentication wizard:\n` +
64
+ ` staticstripes auth --upload-name ${upload.name}\n\n` +
65
+ `Or view detailed setup instructions:\n` +
66
+ ` staticstripes auth-help instagram\n`,
80
67
  );
81
68
  }
82
69
 
@@ -108,6 +95,9 @@ export class InstagramUploadStrategy implements UploadStrategy {
108
95
  // Determine title (use upload-specific title or fall back to project title)
109
96
  const title = upload.title || project.getTitle();
110
97
 
98
+ // Get date from project
99
+ const date = project.getDate();
100
+
111
101
  // Format tags with # and space-separated (Instagram style)
112
102
  const formattedTags = upload.tags.map((tag) => `#${tag}`).join(' ');
113
103
 
@@ -116,6 +106,7 @@ export class InstagramUploadStrategy implements UploadStrategy {
116
106
 
117
107
  const processedCaption = ejs.render(ejsCaption, {
118
108
  title,
109
+ date,
119
110
  tags: formattedTags,
120
111
  });
121
112
 
@@ -329,18 +320,26 @@ export class InstagramUploadStrategy implements UploadStrategy {
329
320
  throw new Error('S3 configuration missing');
330
321
  }
331
322
 
332
- const { endpoint, region, bucket, path } = s3Upload.s3;
323
+ const { endpoint, region, bucket, paths } = s3Upload.s3;
333
324
  const output = project.getOutput(s3Upload.outputName);
334
325
  if (!output) {
335
326
  throw new Error(`Output "${s3Upload.outputName}" not found`);
336
327
  }
337
328
 
329
+ // Get the file path
330
+ const filePath = paths.get('file');
331
+ if (!filePath) {
332
+ throw new Error('S3 upload missing "file" path');
333
+ }
334
+
338
335
  // Interpolate path variables
339
336
  const slug = this.slugify(project.getTitle());
340
337
  const outputName = output.name;
341
- const interpolatedPath = path
338
+ const date = project.getDate() || '';
339
+ const interpolatedPath = filePath
342
340
  .replace(/\$\{slug\}/g, slug)
343
- .replace(/\$\{output\}/g, outputName);
341
+ .replace(/\$\{output\}/g, outputName)
342
+ .replace(/\$\{date\}/g, date);
344
343
 
345
344
  // Construct URL
346
345
  if (endpoint) {
@@ -3,22 +3,14 @@ import { Project } from '../../project';
3
3
  import { Upload } from '../../type';
4
4
  import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
5
5
  import { readFileSync, existsSync } from 'fs';
6
- import { resolve } from 'path';
7
-
8
- /**
9
- * S3 credentials format stored in .auth/<upload-name>.json
10
- */
11
- interface S3Credentials {
12
- accessKeyId: string;
13
- secretAccessKey: string;
14
- }
6
+ import { CredentialsManager, S3Credentials } from '../credentials';
15
7
 
16
8
  /**
17
9
  * S3 upload strategy implementation
18
10
  * Supports generic S3-compatible storage (AWS S3, DigitalOcean Spaces, etc.)
19
11
  */
20
12
  export class S3UploadStrategy implements UploadStrategy {
21
- constructor() {}
13
+ constructor(private credentialsManager?: CredentialsManager) {}
22
14
 
23
15
  getTag(): string {
24
16
  return 's3';
@@ -40,7 +32,14 @@ export class S3UploadStrategy implements UploadStrategy {
40
32
  );
41
33
  }
42
34
 
43
- const { endpoint, region, bucket, path, acl } = upload.s3;
35
+ const { endpoint, region, bucket, paths, acl } = upload.s3;
36
+
37
+ // Validate that we have a "file" path
38
+ if (!paths.has('file')) {
39
+ throw new Error(
40
+ `❌ Error: S3 upload "${upload.name}" missing required <path name="file"> element`,
41
+ );
42
+ }
44
43
 
45
44
  // Validate ACL value if specified
46
45
  const allowedAcls = ['private', 'public-read', 'authenticated-read'];
@@ -52,15 +51,24 @@ export class S3UploadStrategy implements UploadStrategy {
52
51
  );
53
52
  }
54
53
 
55
- // Load credentials from .auth/<upload-name>.json
56
- const authDir = resolve(projectPath, '.auth');
57
- const credentialsPath = resolve(authDir, `${upload.name}.json`);
54
+ // Load credentials from local .auth/<upload-name>.json or global ~/.staticstripes/auth/<upload-name>.json
55
+ const manager =
56
+ this.credentialsManager ||
57
+ new CredentialsManager(projectPath, upload.name);
58
58
 
59
- if (!existsSync(credentialsPath)) {
59
+ let credentials: S3Credentials;
60
+ try {
61
+ credentials = manager.load<S3Credentials>([
62
+ 'accessKeyId',
63
+ 'secretAccessKey',
64
+ ]);
65
+ } catch (error) {
66
+ // Add helpful context about S3 credentials format
67
+ const errorMessage =
68
+ error instanceof Error ? error.message : String(error);
60
69
  throw new Error(
61
- `❌ Error: S3 credentials not found\n\n` +
62
- `Expected location: ${credentialsPath}\n\n` +
63
- `💡 Create a JSON file with your S3 credentials:\n` +
70
+ `${errorMessage}\n\n` +
71
+ `💡 S3 credentials file should contain:\n` +
64
72
  `{\n` +
65
73
  ` "accessKeyId": "YOUR_ACCESS_KEY",\n` +
66
74
  ` "secretAccessKey": "YOUR_SECRET_KEY"\n` +
@@ -71,24 +79,6 @@ export class S3UploadStrategy implements UploadStrategy {
71
79
  );
72
80
  }
73
81
 
74
- console.log(`🔐 Loading credentials from: ${credentialsPath}`);
75
-
76
- let credentials: S3Credentials;
77
- try {
78
- const credentialsJson = readFileSync(credentialsPath, 'utf-8');
79
- credentials = JSON.parse(credentialsJson);
80
-
81
- if (!credentials.accessKeyId || !credentials.secretAccessKey) {
82
- throw new Error('Missing accessKeyId or secretAccessKey');
83
- }
84
- } catch (error) {
85
- throw new Error(
86
- `❌ Error: Failed to parse S3 credentials from ${credentialsPath}\n` +
87
- `Ensure the file contains valid JSON with accessKeyId and secretAccessKey.\n` +
88
- `Error: ${error instanceof Error ? error.message : String(error)}`,
89
- );
90
- }
91
-
92
82
  // Get the output file
93
83
  const output = project.getOutput(upload.outputName);
94
84
  if (!output) {
@@ -102,12 +92,23 @@ export class S3UploadStrategy implements UploadStrategy {
102
92
  );
103
93
  }
104
94
 
105
- // Interpolate path variables
95
+ // Prepare interpolation variables
106
96
  const slug = this.slugify(project.getTitle());
107
97
  const outputName = output.name;
108
- const interpolatedPath = path
109
- .replace(/\$\{slug\}/g, slug)
110
- .replace(/\$\{output\}/g, outputName);
98
+ const date = project.getDate() || '';
99
+ const title = project.getTitle();
100
+ const tags = upload.tags;
101
+
102
+ // Helper function to interpolate path variables
103
+ const interpolatePath = (pathTemplate: string): string => {
104
+ return pathTemplate
105
+ .replace(/\$\{slug\}/g, slug)
106
+ .replace(/\$\{output\}/g, outputName)
107
+ .replace(/\$\{date\}/g, date);
108
+ };
109
+
110
+ // Get and interpolate the file path
111
+ const filePath = interpolatePath(paths.get('file')!);
111
112
 
112
113
  console.log(`\n📦 Preparing S3 upload...`);
113
114
  console.log(` Bucket: ${bucket}`);
@@ -115,8 +116,13 @@ export class S3UploadStrategy implements UploadStrategy {
115
116
  if (endpoint) {
116
117
  console.log(` Endpoint: ${endpoint}`);
117
118
  }
118
- console.log(` Path: ${interpolatedPath}`);
119
- console.log(` File: ${output.path}\n`);
119
+ console.log(` File path: ${filePath}`);
120
+ console.log(` Video file: ${output.path}`);
121
+ if (paths.has('metadata')) {
122
+ const metadataPath = interpolatePath(paths.get('metadata')!);
123
+ console.log(` Metadata path: ${metadataPath}`);
124
+ }
125
+ console.log('');
120
126
 
121
127
  // Configure S3 client
122
128
  const s3Config: any = {
@@ -138,41 +144,80 @@ export class S3UploadStrategy implements UploadStrategy {
138
144
 
139
145
  const s3Client = new S3Client(s3Config);
140
146
 
141
- // Read file
142
- console.log(`📤 Uploading to S3...`);
147
+ // Upload video file
148
+ console.log(`📤 Uploading video file...`);
143
149
  const fileBuffer = readFileSync(output.path);
144
150
 
145
- // Upload file
146
- const uploadParams: any = {
151
+ const fileUploadParams: any = {
147
152
  Bucket: bucket,
148
- Key: interpolatedPath,
153
+ Key: filePath,
149
154
  Body: fileBuffer,
150
155
  ContentType: 'video/mp4',
151
156
  };
152
157
 
153
158
  // Add ACL if specified in configuration
154
159
  if (acl) {
155
- uploadParams.ACL = acl;
160
+ fileUploadParams.ACL = acl;
156
161
  }
157
162
 
158
- const command = new PutObjectCommand(uploadParams);
159
-
160
163
  try {
161
- await s3Client.send(command);
164
+ await s3Client.send(new PutObjectCommand(fileUploadParams));
162
165
 
163
- // Construct public URL
164
- let publicUrl: string;
166
+ // Construct public URL for video
167
+ let fileUrl: string;
165
168
  if (endpoint) {
166
169
  // For DigitalOcean Spaces and similar services
167
170
  // Format: https://{bucket}.{region}.{endpoint}/{path}
168
- publicUrl = `https://${bucket}.${region}.${endpoint}/${interpolatedPath}`;
171
+ fileUrl = `https://${bucket}.${region}.${endpoint}/${filePath}`;
169
172
  } else {
170
173
  // For AWS S3
171
- publicUrl = `https://${bucket}.s3.${region}.amazonaws.com/${interpolatedPath}`;
174
+ fileUrl = `https://${bucket}.s3.${region}.amazonaws.com/${filePath}`;
175
+ }
176
+
177
+ console.log(`✅ Video uploaded successfully!`);
178
+ console.log(`🔗 Video URL: ${fileUrl}`);
179
+
180
+ // Upload metadata file if specified
181
+ if (paths.has('metadata')) {
182
+ console.log(`\n📤 Uploading metadata file...`);
183
+ const metadataPath = interpolatePath(paths.get('metadata')!);
184
+
185
+ // Create metadata JSON
186
+ const metadata = {
187
+ title,
188
+ date: date || null,
189
+ tags,
190
+ };
191
+
192
+ const metadataBuffer = Buffer.from(JSON.stringify(metadata, null, 2), 'utf-8');
193
+
194
+ const metadataUploadParams: any = {
195
+ Bucket: bucket,
196
+ Key: metadataPath,
197
+ Body: metadataBuffer,
198
+ ContentType: 'application/json',
199
+ };
200
+
201
+ // Add ACL if specified
202
+ if (acl) {
203
+ metadataUploadParams.ACL = acl;
204
+ }
205
+
206
+ await s3Client.send(new PutObjectCommand(metadataUploadParams));
207
+
208
+ // Construct public URL for metadata
209
+ let metadataUrl: string;
210
+ if (endpoint) {
211
+ metadataUrl = `https://${bucket}.${region}.${endpoint}/${metadataPath}`;
212
+ } else {
213
+ metadataUrl = `https://${bucket}.s3.${region}.amazonaws.com/${metadataPath}`;
214
+ }
215
+
216
+ console.log(`✅ Metadata uploaded successfully!`);
217
+ console.log(`🔗 Metadata URL: ${metadataUrl}`);
172
218
  }
173
219
 
174
- console.log(`\n✅ Upload successful!`);
175
- console.log(`🔗 Public URL: ${publicUrl}\n`);
220
+ console.log('');
176
221
  } catch (error) {
177
222
  throw new Error(
178
223
  `❌ Error: Failed to upload to S3\n` +
@@ -4,7 +4,7 @@ import { YouTubeUpload } from '../../type';
4
4
  import { resolve } from 'path';
5
5
  import { YouTubeUploader } from '../../youtube-uploader';
6
6
  import ejs from 'ejs';
7
- import { readFileSync, existsSync } from 'fs';
7
+ import { CredentialsManager, YouTubeCredentials } from '../credentials';
8
8
 
9
9
  export interface YouTubeUploadOptions {
10
10
  uploadName: string;
@@ -17,6 +17,8 @@ export interface YouTubeUploadOptions {
17
17
  * YouTube upload strategy implementation
18
18
  */
19
19
  export class YouTubeUploadStrategy implements UploadStrategy {
20
+ constructor(private credentialsManager?: CredentialsManager) {}
21
+
20
22
  getTag(): string {
21
23
  return 'youtube';
22
24
  }
@@ -30,14 +32,23 @@ export class YouTubeUploadStrategy implements UploadStrategy {
30
32
  upload: YouTubeUpload,
31
33
  projectPath: string,
32
34
  ): Promise<void> {
33
- // Read credentials from .auth file
34
- const authDir = resolve(projectPath, '.auth');
35
- const credentialsPath = resolve(authDir, `${upload.name}.json`);
35
+ // Load credentials from local .auth/<upload-name>.json or global ~/.staticstripes/auth/<upload-name>.json
36
+ const manager =
37
+ this.credentialsManager ||
38
+ new CredentialsManager(projectPath, upload.name);
36
39
 
37
- if (!existsSync(credentialsPath)) {
40
+ let credentials: YouTubeCredentials;
41
+ try {
42
+ credentials = manager.load<YouTubeCredentials>([
43
+ 'clientId',
44
+ 'clientSecret',
45
+ ]);
46
+ } catch (error) {
47
+ // Add helpful context about YouTube credentials
48
+ const errorMessage =
49
+ error instanceof Error ? error.message : String(error);
38
50
  throw new Error(
39
- `❌ Error: YouTube credentials not found\n\n` +
40
- `Expected location: ${credentialsPath}\n\n` +
51
+ `${errorMessage}\n\n` +
41
52
  `💡 Run authentication wizard:\n` +
42
53
  ` staticstripes auth --upload-name ${upload.name}\n\n` +
43
54
  `📖 Or view setup instructions:\n` +
@@ -45,22 +56,6 @@ export class YouTubeUploadStrategy implements UploadStrategy {
45
56
  );
46
57
  }
47
58
 
48
- let credentials: { clientId?: string; clientSecret?: string };
49
- try {
50
- const credentialsJson = readFileSync(credentialsPath, 'utf-8');
51
- credentials = JSON.parse(credentialsJson);
52
-
53
- if (!credentials.clientId || !credentials.clientSecret) {
54
- throw new Error('Missing clientId or clientSecret');
55
- }
56
- } catch (error) {
57
- throw new Error(
58
- `❌ Error: Failed to parse YouTube credentials from ${credentialsPath}\n` +
59
- `Ensure the file contains clientId and clientSecret.\n` +
60
- `Error: ${error instanceof Error ? error.message : String(error)}`,
61
- );
62
- }
63
-
64
59
  // Delegate to existing handler
65
60
  await handleYouTubeUpload(project, {
66
61
  uploadName: upload.name,
@@ -116,6 +111,9 @@ export async function handleYouTubeUpload(
116
111
  const title = upload.title || project.getTitle();
117
112
  console.log(`📝 Title: ${title}\n`);
118
113
 
114
+ // Get date from project
115
+ const date = project.getDate();
116
+
119
117
  // Build the project to populate fragment times (needed for timecodes)
120
118
  console.log('🔨 Building project to calculate timecodes...');
121
119
  await project.build(upload.outputName);
@@ -134,6 +132,7 @@ export async function handleYouTubeUpload(
134
132
 
135
133
  const processedDescription = ejs.render(ejsDescription, {
136
134
  title,
135
+ date,
137
136
  tags: formattedTags,
138
137
  timecodes: timecodes.join('\n'),
139
138
  });
@@ -105,6 +105,7 @@ export class HTMLProjectParser {
105
105
  const outputs = this.processOutputs();
106
106
  const ffmpegOptions = this.processFfmpegOptions();
107
107
  const title = this.processTitle();
108
+ const date = this.processDate();
108
109
  const globalTags = this.processGlobalTags();
109
110
  const uploads = this.processUploads(title, globalTags);
110
111
  const sequences = this.processSequences(assets);
@@ -118,6 +119,7 @@ export class HTMLProjectParser {
118
119
  uploads,
119
120
  aiProviders,
120
121
  title,
122
+ date,
121
123
  cssText,
122
124
  this.projectPath,
123
125
  );
@@ -894,7 +896,7 @@ export class HTMLProjectParser {
894
896
  let endpoint: string | undefined;
895
897
  let region = '';
896
898
  let bucket = '';
897
- let path = '';
899
+ const paths = new Map<string, string>();
898
900
  let acl: string | undefined;
899
901
 
900
902
  if ('children' in element && element.children) {
@@ -917,7 +919,23 @@ export class HTMLProjectParser {
917
919
  break;
918
920
  }
919
921
  case 'path': {
920
- path = childAttrs.get('name') || '';
922
+ // Extract path name from name attribute
923
+ const pathName = childAttrs.get('name') || 'file';
924
+
925
+ // Extract path value from text content
926
+ let pathValue = '';
927
+ if ('children' in childElement && childElement.children) {
928
+ for (const textNode of childElement.children) {
929
+ if (textNode.type === 'text' && 'data' in textNode) {
930
+ pathValue += textNode.data;
931
+ }
932
+ }
933
+ }
934
+
935
+ pathValue = pathValue.trim();
936
+ if (pathValue) {
937
+ paths.set(pathName, pathValue);
938
+ }
921
939
  break;
922
940
  }
923
941
  case 'acl': {
@@ -930,7 +948,7 @@ export class HTMLProjectParser {
930
948
  }
931
949
 
932
950
  // Validate required fields
933
- if (!region || !bucket || !path) {
951
+ if (!region || !bucket || paths.size === 0) {
934
952
  console.warn(`S3 upload "${name}" missing required fields (region, bucket, or path)`);
935
953
  return null;
936
954
  }
@@ -949,7 +967,7 @@ export class HTMLProjectParser {
949
967
  endpoint,
950
968
  region,
951
969
  bucket,
952
- path,
970
+ paths,
953
971
  acl,
954
972
  },
955
973
  };
@@ -1242,6 +1260,31 @@ export class HTMLProjectParser {
1242
1260
  return title.trim() || 'Untitled Project';
1243
1261
  }
1244
1262
 
1263
+ /**
1264
+ * Processes the date from the parsed HTML
1265
+ */
1266
+ private processDate(): string | undefined {
1267
+ const dateElements = this.findDateElements();
1268
+
1269
+ if (dateElements.length === 0) {
1270
+ return undefined;
1271
+ }
1272
+
1273
+ // Get text content from first date element
1274
+ const dateElement = dateElements[0];
1275
+ let date = '';
1276
+
1277
+ if ('children' in dateElement && dateElement.children) {
1278
+ for (const textNode of dateElement.children) {
1279
+ if (textNode.type === 'text' && 'data' in textNode) {
1280
+ date += textNode.data;
1281
+ }
1282
+ }
1283
+ }
1284
+
1285
+ return date.trim() || undefined;
1286
+ }
1287
+
1245
1288
  /**
1246
1289
  * Finds all title elements in the HTML (top-level only, not inside uploads)
1247
1290
  */
@@ -1276,6 +1319,43 @@ export class HTMLProjectParser {
1276
1319
  return results;
1277
1320
  }
1278
1321
 
1322
+ /**
1323
+ * Finds all date elements in the HTML (top-level only)
1324
+ */
1325
+ private findDateElements(): Element[] {
1326
+ const results: Element[] = [];
1327
+
1328
+ const traverse = (node: ASTNode, insideProject: boolean = false) => {
1329
+ if (node.type === 'tag') {
1330
+ const element = node as Element;
1331
+
1332
+ // Find top-level <date> tags (not inside <project>, <uploads>, etc.)
1333
+ if (element.name === 'date' && !insideProject) {
1334
+ results.push(element);
1335
+ }
1336
+
1337
+ // Mark that we're inside a project/uploads/outputs section
1338
+ const isProjectSection =
1339
+ element.name === 'project' ||
1340
+ element.name === 'uploads' ||
1341
+ element.name === 'outputs';
1342
+
1343
+ if ('children' in node && node.children) {
1344
+ for (const child of node.children) {
1345
+ traverse(child, insideProject || isProjectSection);
1346
+ }
1347
+ }
1348
+ } else if ('children' in node && node.children) {
1349
+ for (const child of node.children) {
1350
+ traverse(child, insideProject);
1351
+ }
1352
+ }
1353
+ };
1354
+
1355
+ traverse(this.html.ast);
1356
+ return results;
1357
+ }
1358
+
1279
1359
  /**
1280
1360
  * Finds all uploads elements in the HTML
1281
1361
  */
package/src/project.ts CHANGED
@@ -26,6 +26,7 @@ export class Project {
26
26
  private uploads: Map<string, Upload>,
27
27
  private aiProviders: Map<string, AIProvider>,
28
28
  private title: string,
29
+ private date: string | undefined,
29
30
  private cssText: string,
30
31
  private projectPath: string,
31
32
  ) {
@@ -141,6 +142,10 @@ export class Project {
141
142
  return this.title;
142
143
  }
143
144
 
145
+ public getDate(): string | undefined {
146
+ return this.date;
147
+ }
148
+
144
149
  public getCssText(): string {
145
150
  return this.cssText;
146
151
  }
package/src/type.ts CHANGED
@@ -101,7 +101,7 @@ export type Upload = {
101
101
  endpoint?: string; // e.g. "digitaloceanspaces.com"
102
102
  region: string; // e.g. "ams3", "us-east-1"
103
103
  bucket: string; // e.g. "my-bucket"
104
- path: string; // e.g. "videos/${slug}/${output}.mp4"
104
+ paths: Map<string, string>; // e.g. Map { "file" => "videos/${slug}/${output}.mp4", "metadata" => "videos/${slug}/metadata.json" }
105
105
  acl?: string; // e.g. "public-read", "private"
106
106
  };
107
107
  // Instagram-specific configuration