@learnpack/learnpack 5.0.362 → 5.0.364

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 (55) hide show
  1. package/lib/commands/serve.js +444 -279
  2. package/lib/creatorDist/assets/{index-B7s5Nmq8.css → index-Cco3XhDR.css} +96 -0
  3. package/lib/creatorDist/assets/{index-CKg5uuoj.js → index-o3Cw0bsF.js} +69620 -66788
  4. package/lib/creatorDist/index.html +2 -2
  5. package/lib/models/creator.d.ts +1 -0
  6. package/lib/utils/api.d.ts +0 -9
  7. package/lib/utils/api.js +1 -65
  8. package/lib/utils/generationRetry.d.ts +48 -0
  9. package/lib/utils/generationRetry.js +81 -0
  10. package/lib/utils/interactivity/componentsGuide.d.ts +50 -0
  11. package/lib/utils/interactivity/componentsGuide.js +193 -0
  12. package/lib/utils/interactivity/interactivityPlan.d.ts +20 -0
  13. package/lib/utils/interactivity/interactivityPlan.js +33 -0
  14. package/lib/utils/interactivity/structuredComponents.d.ts +34 -0
  15. package/lib/utils/interactivity/structuredComponents.js +792 -0
  16. package/lib/utils/rigoActions.d.ts +1 -1
  17. package/lib/utils/rigoActions.js +2 -2
  18. package/package.json +1 -1
  19. package/src/commands/serve.ts +653 -397
  20. package/src/creator/package-lock.json +297 -3
  21. package/src/creator/package.json +1 -0
  22. package/src/creator/src/App.test.tsx +124 -1
  23. package/src/creator/src/App.tsx +52 -36
  24. package/src/creator/src/components/FileCard.tsx +63 -19
  25. package/src/creator/src/components/FileReadListener.test.tsx +128 -0
  26. package/src/creator/src/components/FileReadListener.tsx +75 -0
  27. package/src/creator/src/components/FileReviewModal.test.tsx +193 -0
  28. package/src/creator/src/components/FileReviewModal.tsx +128 -0
  29. package/src/creator/src/components/FileUploader.test.tsx +229 -0
  30. package/src/creator/src/components/FileUploader.tsx +212 -58
  31. package/src/creator/src/components/GenerationErrorCard.tsx +57 -0
  32. package/src/creator/src/components/MarkdownRenderer.tsx +4 -2
  33. package/src/creator/src/components/syllabus/SyllabusEditor.tsx +6 -9
  34. package/src/creator/src/index.css +45 -0
  35. package/src/creator/src/locales/en.json +27 -5
  36. package/src/creator/src/locales/es.json +27 -5
  37. package/src/creator/src/main.tsx +2 -0
  38. package/src/creator/src/utils/completionResult.test.ts +61 -0
  39. package/src/creator/src/utils/completionResult.ts +61 -2
  40. package/src/creator/src/utils/courseLanguage.test.ts +6 -0
  41. package/src/creator/src/utils/creationRequest.test.ts +53 -18
  42. package/src/creator/src/utils/creationRequest.ts +17 -12
  43. package/src/creator/src/utils/fileRead.ts +60 -0
  44. package/src/creator/src/utils/lib.ts +0 -5
  45. package/src/creator/src/utils/store.ts +0 -10
  46. package/src/creatorDist/assets/{index-B7s5Nmq8.css → index-Cco3XhDR.css} +96 -0
  47. package/src/creatorDist/assets/{index-CKg5uuoj.js → index-o3Cw0bsF.js} +69620 -66788
  48. package/src/creatorDist/index.html +2 -2
  49. package/src/models/creator.ts +87 -84
  50. package/src/utils/api.ts +0 -83
  51. package/src/utils/generationRetry.ts +100 -0
  52. package/src/utils/interactivity/componentsGuide.ts +294 -0
  53. package/src/utils/interactivity/interactivityPlan.ts +57 -0
  54. package/src/utils/interactivity/structuredComponents.ts +1004 -0
  55. package/src/utils/rigoActions.ts +3 -2
@@ -10,8 +10,8 @@
10
10
  />
11
11
 
12
12
  <title>Learnpack Creator: Craft tutorials in seconds!</title>
13
- <script type="module" crossorigin src="/creator/assets/index-CKg5uuoj.js"></script>
14
- <link rel="stylesheet" crossorigin href="/creator/assets/index-B7s5Nmq8.css">
13
+ <script type="module" crossorigin src="/creator/assets/index-o3Cw0bsF.js"></script>
14
+ <link rel="stylesheet" crossorigin href="/creator/assets/index-Cco3XhDR.css">
15
15
  </head>
16
16
  <body>
17
17
  <div id="root"></div>
@@ -1,84 +1,87 @@
1
- export interface SyncNotification {
2
- id: string; // UUID unique identifier
3
- sourceLanguage: string; // Language code from which sync will occur
4
- createdAt: number; // Creation timestamp
5
- updatedAt: number; // Last update timestamp
6
- status: "pending" | "processing" | "completed" | "partial" | "error";
7
- processingLastUpdate?: number; // Last update timestamp during processing
8
- error?: {
9
- message: string;
10
- code: string;
11
- timestamp: number;
12
- };
13
- syncProgress?: {
14
- totalLanguages: number;
15
- completedLanguages: string[]; // Array of completed language codes (no race conditions)
16
- currentLanguage?: string;
17
- failedLanguages?: Array<{
18
- code: string;
19
- error: string;
20
- }>;
21
- };
22
- }
23
-
24
- export interface Lesson {
25
- id: string;
26
- uid: string;
27
- title: string;
28
- type: "READ" | "CODE" | "QUIZ";
29
- description: string;
30
- duration?: number;
31
- generated?: boolean;
32
- status?: "PENDING" | "GENERATING" | "DONE" | "ERROR";
33
- initialContent?: string;
34
- translations?: {
35
- [key: string]: {
36
- completionId: number;
37
- startedAt: number;
38
- completedAt?: number;
39
- error?: boolean;
40
- // --- description fields (per language) ---
41
- // The step description is a projection derived from the README content;
42
- // it lives here (source of truth) and is projected into package-manifest.json.
43
- description?: string | null;
44
- descriptionStatus?: "generated" | "pending" | "error" | "none";
45
- sourceContentHash?: string; // SHA-256 of the normalized README it was generated from
46
- sourceSimHash?: string; // SimHash-64 (hex) used for the significance threshold
47
- descriptionPromptVersion?: number;
48
- descriptionSource?: "auto" | "human";
49
- };
50
- };
51
- syncNotifications?: SyncNotification[];
52
- }
53
-
54
- export interface ParsedLink {
55
- name: string;
56
- text: string;
57
- }
58
-
59
- export type TDifficulty = "easy" | "beginner" | "intermediate" | "hard";
60
-
61
- export type FormState = {
62
- description: string;
63
- duration: number;
64
- hasContentIndex: boolean;
65
- difficulty: TDifficulty;
66
- contentIndex: string;
67
- language?: string;
68
- technologies?: string[];
69
- isCompleted: boolean;
70
- variables: string[];
71
- currentStep: string;
72
- title: string;
73
- purpose: string;
74
- slug: string;
75
- };
76
-
77
- export type Syllabus = {
78
- lessons: Lesson[];
79
- courseInfo: FormState;
80
- feedback?: string;
81
- generationMode?: "next-three" | "continue-with-all";
82
- used_components?: { [componentName: string]: number };
83
- // sources: ParsedLink[]
84
- };
1
+ export interface SyncNotification {
2
+ id: string; // UUID unique identifier
3
+ sourceLanguage: string; // Language code from which sync will occur
4
+ createdAt: number; // Creation timestamp
5
+ updatedAt: number; // Last update timestamp
6
+ status: "pending" | "processing" | "completed" | "partial" | "error";
7
+ processingLastUpdate?: number; // Last update timestamp during processing
8
+ error?: {
9
+ message: string;
10
+ code: string;
11
+ timestamp: number;
12
+ };
13
+ syncProgress?: {
14
+ totalLanguages: number;
15
+ completedLanguages: string[]; // Array of completed language codes (no race conditions)
16
+ currentLanguage?: string;
17
+ failedLanguages?: Array<{
18
+ code: string;
19
+ error: string;
20
+ }>;
21
+ };
22
+ }
23
+
24
+ export interface Lesson {
25
+ id: string;
26
+ uid: string;
27
+ title: string;
28
+ type: "READ" | "CODE" | "QUIZ";
29
+ description: string;
30
+ duration?: number;
31
+ generated?: boolean;
32
+ status?: "PENDING" | "GENERATING" | "DONE" | "ERROR";
33
+ initialContent?: string;
34
+ translations?: {
35
+ [key: string]: {
36
+ completionId: number;
37
+ startedAt: number;
38
+ completedAt?: number;
39
+ error?: boolean;
40
+ // Automatic retries of the current generation phase, capped by
41
+ // MAX_AUTO_RETRIES in utils/generationRetry.ts.
42
+ autoRetries?: number;
43
+ // --- description fields (per language) ---
44
+ // The step description is a projection derived from the README content;
45
+ // it lives here (source of truth) and is projected into package-manifest.json.
46
+ description?: string | null;
47
+ descriptionStatus?: "generated" | "pending" | "error" | "none";
48
+ sourceContentHash?: string; // SHA-256 of the normalized README it was generated from
49
+ sourceSimHash?: string; // SimHash-64 (hex) used for the significance threshold
50
+ descriptionPromptVersion?: number;
51
+ descriptionSource?: "auto" | "human";
52
+ };
53
+ };
54
+ syncNotifications?: SyncNotification[];
55
+ }
56
+
57
+ export interface ParsedLink {
58
+ name: string;
59
+ text: string;
60
+ }
61
+
62
+ export type TDifficulty = "easy" | "beginner" | "intermediate" | "hard";
63
+
64
+ export type FormState = {
65
+ description: string;
66
+ duration: number;
67
+ hasContentIndex: boolean;
68
+ difficulty: TDifficulty;
69
+ contentIndex: string;
70
+ language?: string;
71
+ technologies?: string[];
72
+ isCompleted: boolean;
73
+ variables: string[];
74
+ currentStep: string;
75
+ title: string;
76
+ purpose: string;
77
+ slug: string;
78
+ };
79
+
80
+ export type Syllabus = {
81
+ lessons: Lesson[];
82
+ courseInfo: FormState;
83
+ feedback?: string;
84
+ generationMode?: "next-three" | "continue-with-all";
85
+ used_components?: { [componentName: string]: number };
86
+ // sources: ParsedLink[]
87
+ };
package/src/utils/api.ts CHANGED
@@ -776,87 +776,6 @@ const createRigoPackage = async (token: string, slug: string, config: any) => {
776
776
  }
777
777
  }
778
778
 
779
- type TTechnology = {
780
- slug: string;
781
- lang: string;
782
- };
783
-
784
- let technologiesCache: TTechnology[] = []
785
-
786
- /**
787
- * Strip .env comments (e.g. "token # comment") so the token is sent without spaces.
788
- * @param token - Raw token value from env, possibly with trailing comment.
789
- * @returns Trimmed token with inline `#` comments removed, or empty string if missing.
790
- */
791
- function sanitizeToken(token: string | undefined): string {
792
- if (!token) return ""
793
- const trimmed = token.trim()
794
- const beforeComment = trimmed.split(/\s+#/)[0].trim()
795
- return beforeComment
796
- }
797
-
798
- export const fetchTechnologies = async () => {
799
- const rawToken = process.env.BREATHECODE_PERMANENT_TOKEN
800
- const BREATHECODE_PERMANENT_TOKEN = sanitizeToken(rawToken)
801
- const LANGS = ["en", "es"]
802
-
803
- if (!BREATHECODE_PERMANENT_TOKEN) {
804
- throw new Error(
805
- "BREATHECODE_PERMANENT_TOKEN is not defined in environment variables"
806
- )
807
- }
808
-
809
- const headers = {
810
- Authorization: `Token ${BREATHECODE_PERMANENT_TOKEN}`,
811
- }
812
-
813
- const results = await Promise.all(
814
- LANGS.map(lang =>
815
- axios
816
- .get(`${HOST}/v1/registry/technology?lang=${lang}`, { headers })
817
- .then(res => {
818
- return res.data
819
- })
820
- .then(data =>
821
- data.map((item: any) => ({
822
- slug: item.slug,
823
- lang: lang,
824
- }))
825
- )
826
- )
827
- )
828
-
829
- const allItems = results.flat()
830
-
831
- // Remove duplicates by slug+lang combination
832
- const unique = []
833
- const seen = new Set()
834
- for (const item of allItems) {
835
- const key = `${item.slug}:${item.lang}`
836
- if (!seen.has(key)) {
837
- seen.add(key)
838
- unique.push(item)
839
- }
840
- }
841
-
842
- return unique
843
- }
844
-
845
- // Function to update the cache and schedule the next update
846
- async function updateTechnologiesPeriodically() {
847
- try {
848
- technologiesCache = await fetchTechnologies()
849
- // Uncomment for debugging:
850
- // console.log('Technologies list updated:', technologiesCache);
851
- } catch (error: any) {
852
- console.error("Error updating technologies list:", error)
853
- } finally {
854
- setTimeout(updateTechnologiesPeriodically, 24 * 60 * 60 * 1000)
855
- }
856
- }
857
-
858
- export const getCurrentTechnologies = () => technologiesCache
859
-
860
779
  export default {
861
780
  login,
862
781
  publish,
@@ -876,6 +795,4 @@ export default {
876
795
  getCategories,
877
796
  updateRigoPackage,
878
797
  createRigoPackage,
879
- getCurrentTechnologies,
880
- updateTechnologiesPeriodically,
881
798
  }
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Automatic retries for the two lesson generation phases. Without a cap, an
3
+ * error that always repeats, like a broken prompt template, starts jobs
4
+ * forever: Rigobot fails in under a second and the webhook starts another one.
5
+ */
6
+
7
+ /** Automatic retries allowed per phase, after the first job. */
8
+ export const MAX_AUTO_RETRIES = 2
9
+
10
+ /**
11
+ * Errors from Rigobot that repeat on every attempt, so retrying only spends
12
+ * jobs. They come from the template, the inputs or the model configuration.
13
+ */
14
+ const PERMANENT_ERRORS: RegExp[] = [
15
+ // A template placeholder without an input.
16
+ /^Missing key /,
17
+ // Braces in a template body that Python's str.format cannot parse.
18
+ /format string|in field name|out of range for positional args/,
19
+ // The purpose's engine cannot return the template's fields.
20
+ /doesn't support structured outputs/,
21
+ // The model API rejected the request itself. 429 and 5xx are not here,
22
+ // because they can pass.
23
+ /^Error code: (400|401|403|404|422)\b/,
24
+ ]
25
+
26
+ /**
27
+ * Whether a failed job would fail the same way if started again.
28
+ * @param statusText - The `status_text` Rigobot sends with the error.
29
+ * @returns True when retrying cannot help.
30
+ */
31
+ export const isPermanentGenerationError = (statusText: unknown): boolean => {
32
+ if (typeof statusText !== "string") return false
33
+
34
+ const text = statusText.trim()
35
+ // A Python KeyError prints as its quoted key: a placeholder in the template
36
+ // that matches no input.
37
+ if (/^["']/.test(text) || text.startsWith("{'")) return true
38
+
39
+ return PERMANENT_ERRORS.some(pattern => pattern.test(text))
40
+ }
41
+
42
+ type LessonState = {
43
+ status?: string;
44
+ translations?: {
45
+ [language: string]: { completionId?: number; autoRetries?: number };
46
+ };
47
+ };
48
+
49
+ /**
50
+ * Whether a webhook belongs to a job the lesson no longer waits for. That
51
+ * happens when Rigobot sends a second webhook for a finished job, or when an
52
+ * old job answers after a newer one started.
53
+ * @param lesson - The lesson as saved in the syllabus.
54
+ * @param language - The course language.
55
+ * @param jobId - The id of the job the webhook reports.
56
+ * @returns True when the webhook must not change the lesson.
57
+ */
58
+ export const isStaleWebhook = (
59
+ lesson: LessonState,
60
+ language: string,
61
+ jobId: unknown
62
+ ): boolean => {
63
+ if (lesson.status === "DONE") return true
64
+
65
+ const current = lesson.translations?.[language]?.completionId
66
+ // Job ids grow, so a lower id is an older job. Without both ids, nothing
67
+ // tells the webhook apart, and it is handled.
68
+ return (
69
+ typeof jobId === "number" &&
70
+ typeof current === "number" &&
71
+ current > 0 &&
72
+ jobId < current
73
+ )
74
+ }
75
+
76
+ export type RetryPlan =
77
+ | { retry: true; attempt: number }
78
+ | { retry: false; reason: "permanent" | "exhausted" };
79
+
80
+ /**
81
+ * Decides whether a failed job starts another one.
82
+ * @param lesson - The lesson as saved in the syllabus.
83
+ * @param language - The course language.
84
+ * @param statusText - The `status_text` Rigobot sends with the error.
85
+ * @returns The retry to start, numbered from 1, or why there is none.
86
+ */
87
+ export const planGenerationRetry = (
88
+ lesson: LessonState,
89
+ language: string,
90
+ statusText: unknown
91
+ ): RetryPlan => {
92
+ if (isPermanentGenerationError(statusText)) {
93
+ return { retry: false, reason: "permanent" }
94
+ }
95
+
96
+ const used = lesson.translations?.[language]?.autoRetries ?? 0
97
+ if (used >= MAX_AUTO_RETRIES) return { retry: false, reason: "exhausted" }
98
+
99
+ return { retry: true, attempt: used + 1 }
100
+ }
@@ -0,0 +1,294 @@
1
+ // Loads docs/components_v2.yml from learnpack/ide and turns it into the
2
+ // COMPONENTS input of the structured interactivity template.
3
+ //
4
+ // The file is the single source of truth for component names and for the
5
+ // guidance the model receives. Without it the model does not know what each
6
+ // field means, so a generation must not start when no valid copy is available.
7
+
8
+ import axios from "axios"
9
+ import * as yaml from "js-yaml"
10
+ import { COMPONENT_NAMES, DecidedBy } from "./structuredComponents"
11
+
12
+ export const COMPONENTS_V2_URL =
13
+ "https://raw.githubusercontent.com/learnpack/ide/refs/heads/master/docs/components_v2.yml"
14
+
15
+ export interface GuideComponent {
16
+ name: string;
17
+ category: string;
18
+ decidedBy: DecidedBy;
19
+ choose: {
20
+ summary: string;
21
+ goodFor?: string[];
22
+ avoid?: string[];
23
+ tests?: string[];
24
+ };
25
+ write: {
26
+ fields: Record<string, string>;
27
+ rules?: string[];
28
+ };
29
+ visualStyle?: string;
30
+ }
31
+
32
+ export interface ComponentsGuide {
33
+ version: string;
34
+ components: GuideComponent[];
35
+ }
36
+
37
+ export class ComponentsGuideUnavailableError extends Error {
38
+ constructor(message: string) {
39
+ super(message)
40
+ this.name = "ComponentsGuideUnavailableError"
41
+ Object.setPrototypeOf(this, ComponentsGuideUnavailableError.prototype)
42
+ }
43
+ }
44
+
45
+ const isNonEmptyString = (value: unknown): value is string =>
46
+ typeof value === "string" && value.trim().length > 0
47
+
48
+ const isPlainObject = (value: unknown): value is Record<string, any> =>
49
+ value !== null && typeof value === "object" && !Array.isArray(value)
50
+
51
+ // Parses and checks the guide. It is lenient on purpose: the IDE repository
52
+ // can add or rename a component before this CLI knows it, and that must not
53
+ // stop every generation. Unknown or incomplete components are skipped with a
54
+ // warning; the guide is unavailable only when it does not parse or has no
55
+ // usable component.
56
+ export const parseComponentsGuide = (
57
+ text: string
58
+ ): { guide: ComponentsGuide; warnings: string[] } => {
59
+ let document: any
60
+ try {
61
+ document = yaml.load(text)
62
+ } catch (error) {
63
+ throw new ComponentsGuideUnavailableError(
64
+ `components_v2.yml is not valid YAML: ${(error as Error).message}`
65
+ )
66
+ }
67
+
68
+ if (!isPlainObject(document) || !Array.isArray(document.components)) {
69
+ throw new ComponentsGuideUnavailableError(
70
+ "components_v2.yml has no components list"
71
+ )
72
+ }
73
+
74
+ const warnings: string[] = []
75
+ const known = new Set<string>(COMPONENT_NAMES)
76
+ const seen = new Set<string>()
77
+ const components: GuideComponent[] = []
78
+
79
+ for (const raw of document.components) {
80
+ const name =
81
+ isPlainObject(raw) && isNonEmptyString(raw.name) ? raw.name : "(no name)"
82
+ if (!known.has(name)) {
83
+ warnings.push(
84
+ `components_v2.yml: "${name}" is not a component this CLI can render; skipped`
85
+ )
86
+ continue
87
+ }
88
+
89
+ if (seen.has(name)) {
90
+ warnings.push(
91
+ `components_v2.yml: "${name}" appears twice; the second one is skipped`
92
+ )
93
+ continue
94
+ }
95
+
96
+ const decidedBy = raw.decidedBy
97
+ if (
98
+ (decidedBy !== "syllabus" && decidedBy !== "interactivity") ||
99
+ !isPlainObject(raw.choose) ||
100
+ !isNonEmptyString(raw.choose.summary) ||
101
+ !isPlainObject(raw.write) ||
102
+ !isPlainObject(raw.write.fields)
103
+ ) {
104
+ warnings.push(
105
+ `components_v2.yml: "${name}" lacks decidedBy, choose.summary or write.fields; skipped`
106
+ )
107
+ continue
108
+ }
109
+
110
+ seen.add(name)
111
+ components.push(raw as GuideComponent)
112
+ }
113
+
114
+ if (components.length === 0) {
115
+ throw new ComponentsGuideUnavailableError(
116
+ "components_v2.yml has no usable component"
117
+ )
118
+ }
119
+
120
+ for (const name of COMPONENT_NAMES) {
121
+ if (!seen.has(name)) {
122
+ warnings.push(
123
+ `components_v2.yml: "${name}" is missing; the model can still return it, without guidance`
124
+ )
125
+ }
126
+ }
127
+
128
+ return {
129
+ guide: { version: String(document.version || ""), components },
130
+ warnings,
131
+ }
132
+ }
133
+
134
+ // The COMPONENTS input of the template: what each field means and the
135
+ // writing rules, plus the choice criteria. Full criteria only go to the
136
+ // components this step decides; for the ones the syllabus plans, the summary
137
+ // is enough to pick a format when a step has no plan. Syntax never goes in.
138
+ export const buildInteractivityGuide = (guide: ComponentsGuide): string => {
139
+ const components = guide.components.map(component => {
140
+ const choose =
141
+ component.decidedBy === "interactivity" ?
142
+ component.choose :
143
+ { summary: component.choose.summary }
144
+ const entry: Record<string, unknown> = {
145
+ name: component.name,
146
+ choose,
147
+ write: component.write,
148
+ }
149
+ return entry
150
+ })
151
+ return yaml.dump({ components }, { lineWidth: -1, noRefs: true })
152
+ }
153
+
154
+ export const decidedByFromGuide = (
155
+ guide: ComponentsGuide
156
+ ): Record<string, DecidedBy> => {
157
+ const result: Record<string, DecidedBy> = {}
158
+ for (const component of guide.components)
159
+ result[component.name] = component.decidedBy
160
+ return result
161
+ }
162
+
163
+ type Fetcher = (url: string, timeoutMs: number) => Promise<string>;
164
+
165
+ const defaultFetcher: Fetcher = async (url, timeoutMs) => {
166
+ const response = await axios.get(url, {
167
+ timeout: timeoutMs,
168
+ responseType: "text",
169
+ transformResponse: [(data: unknown) => data],
170
+ })
171
+ return String(response.data)
172
+ }
173
+
174
+ // Where the guide is downloaded from. COMPONENTS_V2_URL overrides master, for
175
+ // example with the raw URL of a branch, to test a guide before merging it:
176
+ // master is read by every hosted generation. Never set it in production.
177
+ export const resolveComponentsGuideUrl = (
178
+ env: Record<string, string | undefined> = process.env
179
+ ): string => env.COMPONENTS_V2_URL || COMPONENTS_V2_URL
180
+
181
+ export interface GuideLoaderOptions {
182
+ // Fixed URL; when absent, resolveComponentsGuideUrl() is read on every
183
+ // download, so the .env file applies whatever the import order.
184
+ url?: string;
185
+ fetcher?: Fetcher;
186
+ timeoutMs?: number;
187
+ // Waits between attempts; there is one attempt more than waits.
188
+ retryDelaysMs?: number[];
189
+ maxAgeMs?: number;
190
+ now?: () => number;
191
+ sleep?: (ms: number) => Promise<void>;
192
+ log?: (message: string) => void;
193
+ }
194
+
195
+ export interface GuideLoader {
196
+ // The guide, from memory when possible. Throws
197
+ // ComponentsGuideUnavailableError when there is no valid copy.
198
+ load(): Promise<ComponentsGuide>;
199
+ // The copy in memory, if any. Never fetches and never throws.
200
+ peek(): ComponentsGuide | null;
201
+ }
202
+
203
+ // Keeps the last valid copy in memory (per process). A copy older than
204
+ // maxAgeMs is still served while a new one is fetched in the background
205
+ // (stale-while-revalidate), so a GitHub hiccup does not stop a generation that
206
+ // already had the guide. Concurrent calls share one download.
207
+ export const createGuideLoader = (
208
+ options: GuideLoaderOptions = {}
209
+ ): GuideLoader => {
210
+ const fetcher = options.fetcher || defaultFetcher
211
+ const timeoutMs = options.timeoutMs ?? 5000
212
+ const retryDelaysMs = options.retryDelaysMs || [1000, 2000]
213
+ const maxAgeMs = options.maxAgeMs ?? 10 * 60 * 1000
214
+ const now = options.now || Date.now
215
+ const sleep =
216
+ options.sleep ||
217
+ ((ms: number) =>
218
+ new Promise<void>(resolve => {
219
+ setTimeout(resolve, ms)
220
+ }))
221
+ const log = options.log || ((message: string) => console.warn(message))
222
+
223
+ let cached: { guide: ComponentsGuide; fetchedAt: number } | null = null
224
+ let inFlight: Promise<ComponentsGuide> | null = null
225
+
226
+ const fetchAndParse = async (): Promise<ComponentsGuide> => {
227
+ const url = options.url || resolveComponentsGuideUrl()
228
+ if (url !== COMPONENTS_V2_URL) {
229
+ log(`Loading the components guide from ${url} instead of master`)
230
+ }
231
+
232
+ let lastError = "unknown error"
233
+ for (let attempt = 0; attempt <= retryDelaysMs.length; attempt++) {
234
+ // eslint-disable-next-line no-await-in-loop -- attempts are sequential on purpose
235
+ if (attempt > 0) await sleep(retryDelaysMs[attempt - 1])
236
+ let text: string
237
+ try {
238
+ // eslint-disable-next-line no-await-in-loop -- attempts are sequential on purpose
239
+ text = await fetcher(url, timeoutMs)
240
+ } catch (error) {
241
+ lastError = `could not download ${url}: ${(error as Error).message}`
242
+ continue
243
+ }
244
+
245
+ // A file that does not parse will not parse on the next attempt either.
246
+ const { guide, warnings } = parseComponentsGuide(text)
247
+ for (const warning of warnings) log(warning)
248
+ return guide
249
+ }
250
+
251
+ throw new ComponentsGuideUnavailableError(lastError)
252
+ }
253
+
254
+ const refresh = (): Promise<ComponentsGuide> => {
255
+ if (!inFlight) {
256
+ inFlight = fetchAndParse().then(
257
+ guide => {
258
+ cached = { guide, fetchedAt: now() }
259
+ inFlight = null
260
+ return guide
261
+ },
262
+ error => {
263
+ inFlight = null
264
+ throw error
265
+ }
266
+ )
267
+ }
268
+
269
+ return inFlight
270
+ }
271
+
272
+ return {
273
+ async load() {
274
+ if (cached) {
275
+ if (now() - cached.fetchedAt > maxAgeMs) {
276
+ refresh().catch(error =>
277
+ log(
278
+ `Could not refresh the components guide, using the copy in memory: ${error.message}`
279
+ )
280
+ )
281
+ }
282
+
283
+ return cached.guide
284
+ }
285
+
286
+ return refresh()
287
+ },
288
+ peek() {
289
+ return cached ? cached.guide : null
290
+ },
291
+ }
292
+ }
293
+
294
+ export const componentsGuideLoader = createGuideLoader()