diff --git a/.changeset/warm-files-protect.md b/.changeset/warm-files-protect.md
new file mode 100644
index 00000000..604cad66
--- /dev/null
+++ b/.changeset/warm-files-protect.md
@@ -0,0 +1,7 @@
+---
+"@replexica/spec": minor
+"@replexica/cli": minor
+"replexica": minor
+---
+
+vtt loader
diff --git a/demo/next-app/next-env.d.ts b/demo/next-app/next-env.d.ts
new file mode 100644
index 00000000..40c3d680
--- /dev/null
+++ b/demo/next-app/next-env.d.ts
@@ -0,0 +1,5 @@
+///
+///
+
+// NOTE: This file should not be edited
+// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.
diff --git a/packages/cli/package.json b/packages/cli/package.json
index 771320ca..722b80cf 100644
--- a/packages/cli/package.json
+++ b/packages/cli/package.json
@@ -47,6 +47,7 @@
"lodash": "^4.17.21",
"markdown-it": "^14.1.0",
"markdown-it-front-matter": "^0.2.4",
+ "node-webvtt": "^1.9.4",
"marked": "^15.0.4",
"object-hash": "^3.0.0",
"open": "^10.1.0",
diff --git a/packages/cli/src/loaders/index.spec.ts b/packages/cli/src/loaders/index.spec.ts
index a0095c06..e3fb50d8 100644
--- a/packages/cli/src/loaders/index.spec.ts
+++ b/packages/cli/src/loaders/index.spec.ts
@@ -795,6 +795,100 @@ user.password=Contraseña
expect(fs.writeFile).toHaveBeenCalledWith("i18n/es.yaml", expectedOutput, { encoding: "utf-8", flag: "w" });
});
});
+
+ describe("vtt bucket loader", () => {
+ it("should load complex vtt data", async () => {
+ setupFileMocks();
+
+ const input = `
+ WEBVTT
+
+00:00:00.000 --> 00:00:01.000
+Hello world!
+
+00:00:30.000 --> 00:00:31.000 align:start line:0%
+This is a subtitle
+
+00:01:00.000 --> 00:01:01.000
+Foo
+
+00:01:50.000 --> 00:01:51.000
+Bar
+ `.trim();
+
+ const expectedOutput = {
+ "0#0-1#": "Hello world!",
+ "1#30-31#": "This is a subtitle",
+ "2#60-61#": "Foo",
+ "3#110-111#": "Bar",
+ };
+
+ mockFileOperations(input);
+
+ const vttLoader = createBucketLoader("vtt", "i18n/[locale].vtt");
+ vttLoader.setDefaultLocale("en");
+ const data = await vttLoader.pull("en");
+
+ expect(data).toEqual(expectedOutput);
+ });
+
+ it("should save complex vtt data", async () => {
+ setupFileMocks();
+ const input = `
+ WEBVTT
+
+00:00:00.000 --> 00:00:01.000
+Hello world!
+
+00:00:30.000 --> 00:00:31.000 align:start line:0%
+This is a subtitle
+
+00:01:00.000 --> 00:01:01.000
+Foo
+
+00:01:50.000 --> 00:01:51.000
+Bar
+ `.trim();
+
+ // // Complex VTT payload to save
+ const payload = {
+ "0#0-1#": "¡Hola mundo!",
+ "1#30-31#": "Este es un subtítulo",
+ "2#60-61#": "Foo",
+ "3#110-111#": "Bar",
+ };
+
+ const expectedOutput =
+ `
+ WEBVTT
+
+00:00:00.000 --> 00:00:01.000
+¡Hola mundo!
+
+00:00:30.000 --> 00:00:31.000
+Este es un subtítulo
+
+00:01:00.000 --> 00:01:01.000
+Foo
+
+00:01:50.000 --> 00:01:51.000
+Bar`.trim() + "\n";
+
+ mockFileOperations(input);
+
+ const vttLoader = createBucketLoader("vtt", "i18n/[locale].vtt");
+ vttLoader.setDefaultLocale("en");
+ await vttLoader.pull("en");
+
+ await vttLoader.push("es", payload);
+
+ expect(fs.writeFile).toHaveBeenCalledWith("i18n/es.vtt", expectedOutput, {
+ encoding: "utf-8",
+ flag: "w",
+ });
+ });
+ });
+
describe("XML bucket loader", () => {
it("should load XML data", async () => {
setupFileMocks();
@@ -823,6 +917,7 @@ user.password=Contraseña
const xmlLoader = createBucketLoader("xml", "i18n/[locale].xml");
xmlLoader.setDefaultLocale("en");
const data = await xmlLoader.pull("en");
+
expect(data).toEqual(expectedOutput);
});
diff --git a/packages/cli/src/loaders/index.ts b/packages/cli/src/loaders/index.ts
index ab29faf8..405203e2 100644
--- a/packages/cli/src/loaders/index.ts
+++ b/packages/cli/src/loaders/index.ts
@@ -23,6 +23,7 @@ import createXliffLoader from "./xliff";
import createXmlLoader from "./xml";
import createSrtLoader from "./srt";
import createDatoLoader from "./dato";
+import createVttLoader from "./vtt";
import createVariableLoader from "./variable";
import createSyncLoader from "./sync";
@@ -175,5 +176,13 @@ export default function createBucketLoader(
createFlatLoader(),
createUnlocalizableLoader(),
);
+ case "vtt":
+ return composeLoaders(
+ createTextFileLoader(bucketPathPattern),
+ createVttLoader(),
+ createFlatLoader(),
+ createSyncLoader(),
+ createUnlocalizableLoader(),
+ );
}
}
diff --git a/packages/cli/src/loaders/vtt.ts b/packages/cli/src/loaders/vtt.ts
new file mode 100644
index 00000000..9e51003b
--- /dev/null
+++ b/packages/cli/src/loaders/vtt.ts
@@ -0,0 +1,47 @@
+import webvtt from "node-webvtt";
+import { ILoader } from "./_types";
+import { createLoader } from "./_utils";
+
+export default function createVttLoader(): ILoader<
+ string,
+ Record
+> {
+ return createLoader({
+ async pull(locale, input) {
+ const vtt = webvtt.parse(input)?.cues;
+ if (Object.keys(vtt).length === 0) {
+ return {};
+ } else {
+ return vtt.reduce((result: any, cue: any, index: number) => {
+ const key = `${index}#${cue.start}-${cue.end}#${cue.identifier}`;
+ result[key] = cue.text;
+ return result;
+ }, {});
+ }
+ },
+ async push(locale, payload) {
+ const output = Object.entries(payload).map(([key, text]) => {
+ const [id, timeRange, identifier] = key.split("#");
+ const [startTime, endTime] = timeRange.split("-");
+
+ return {
+ end: Number(endTime),
+ identifier: identifier,
+ start: Number(startTime),
+ styles: "",
+ text: text,
+ };
+ });
+
+ console.log(payload, output);
+
+ const input = {
+ valid: true,
+ strict: true,
+ cues: output,
+ };
+
+ return webvtt.compile(input);
+ },
+ });
+}
diff --git a/packages/cli/types/vtt.d.ts b/packages/cli/types/vtt.d.ts
new file mode 100644
index 00000000..ba350796
--- /dev/null
+++ b/packages/cli/types/vtt.d.ts
@@ -0,0 +1,4 @@
+declare module "node-webvtt" {
+ export function parse(data: string): any;
+ export function compile(data: any): string;
+}
diff --git a/packages/spec/src/formats.ts b/packages/spec/src/formats.ts
index 39498de4..517e81fa 100644
--- a/packages/spec/src/formats.ts
+++ b/packages/spec/src/formats.ts
@@ -19,6 +19,7 @@ export const bucketTypes = [
"srt",
"dato",
"compiler",
+ "vtt",
] as const;
export const bucketTypeSchema = Z.enum(bucketTypes);