@learnpack/learnpack 5.0.54 → 5.0.58
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/README.md +30 -12
- package/lib/commands/serve.d.ts +7 -0
- package/lib/commands/serve.js +277 -0
- package/lib/managers/server/routes.js +2 -0
- package/lib/utils/api.js +1 -1
- package/lib/utils/cloudStorage.d.ts +8 -0
- package/lib/utils/cloudStorage.js +17 -0
- package/oclif.manifest.json +1 -1
- package/package.json +4 -1
- package/src/commands/serve.ts +371 -0
- package/src/creator/README.md +54 -0
- package/src/creator/eslint.config.js +28 -0
- package/src/creator/index.html +13 -0
- package/src/creator/package-lock.json +4659 -0
- package/src/creator/package.json +41 -0
- package/src/creator/public/vite.svg +1 -0
- package/src/creator/src/App.css +42 -0
- package/src/creator/src/App.tsx +221 -0
- package/src/creator/src/assets/react.svg +1 -0
- package/src/creator/src/assets/svgs.tsx +88 -0
- package/src/creator/src/components/Loader.tsx +28 -0
- package/src/creator/src/components/Login.tsx +263 -0
- package/src/creator/src/components/SelectableCard.tsx +30 -0
- package/src/creator/src/components/StepWizard.tsx +77 -0
- package/src/creator/src/components/SyllabusEditor.tsx +431 -0
- package/src/creator/src/index.css +68 -0
- package/src/creator/src/main.tsx +19 -0
- package/src/creator/src/utils/configTypes.ts +122 -0
- package/src/creator/src/utils/constants.ts +2 -0
- package/src/creator/src/utils/lib.ts +36 -0
- package/src/creator/src/utils/rigo.ts +391 -0
- package/src/creator/src/utils/store.ts +78 -0
- package/src/creator/src/vite-env.d.ts +1 -0
- package/src/creator/tsconfig.app.json +26 -0
- package/src/creator/tsconfig.json +7 -0
- package/src/creator/tsconfig.node.json +24 -0
- package/src/creator/vite.config.ts +13 -0
- package/src/creatorDist/assets/index-D92OoEoU.js +23719 -0
- package/src/creatorDist/assets/index-tt9JBVY0.css +987 -0
- package/src/creatorDist/index.html +14 -0
- package/src/creatorDist/vite.svg +1 -0
- package/src/managers/server/routes.ts +3 -0
- package/src/ui/_app/app.css +1 -0
- package/src/ui/_app/app.js +3025 -0
- package/src/ui/_app/favicon.ico +0 -0
- package/src/ui/_app/index.html +109 -0
- package/src/ui/_app/index.html.backup +91 -0
- package/src/ui/_app/learnpack.svg +7 -0
- package/src/ui/_app/logo-192.png +0 -0
- package/src/ui/_app/logo-512.png +0 -0
- package/src/ui/_app/logo.png +0 -0
- package/src/ui/_app/manifest.webmanifest +21 -0
- package/src/ui/_app/sw.js +30 -0
- package/src/ui/app.tar.gz +0 -0
- package/src/utils/api.ts +1 -1
- package/src/utils/cloudStorage.ts +24 -0
- package/src/utils/convertCreds.js +30 -0
@@ -0,0 +1,371 @@
|
|
1
|
+
import { flags } from "@oclif/command"
|
2
|
+
import * as express from "express"
|
3
|
+
import * as cors from "cors"
|
4
|
+
import * as path from "path"
|
5
|
+
import SessionCommand from "../utils/SessionCommand"
|
6
|
+
import { Storage } from "@google-cloud/storage"
|
7
|
+
import { downloadEditor, decompress } from "../managers/file"
|
8
|
+
import * as fs from "fs"
|
9
|
+
import { translateExercise } from "../utils/rigoActions"
|
10
|
+
import * as dotenv from "dotenv"
|
11
|
+
dotenv.config()
|
12
|
+
|
13
|
+
const frontMatter = require("front-matter")
|
14
|
+
|
15
|
+
type ExerciseMap = {
|
16
|
+
[slug: string]: {
|
17
|
+
title: string
|
18
|
+
slug: string
|
19
|
+
graded: boolean
|
20
|
+
files: string[]
|
21
|
+
translations: Record<string, string>
|
22
|
+
}
|
23
|
+
}
|
24
|
+
|
25
|
+
const crendsEnv = process.env.GCP_CREDENTIALS_JSON
|
26
|
+
if (!crendsEnv) {
|
27
|
+
console.log("GCP_CREDENTIALS_JSON is not set")
|
28
|
+
process.exit(1)
|
29
|
+
}
|
30
|
+
|
31
|
+
const credentials = JSON.parse(crendsEnv)
|
32
|
+
|
33
|
+
const bucketStorage = new Storage({
|
34
|
+
credentials,
|
35
|
+
})
|
36
|
+
|
37
|
+
const bucket = bucketStorage.bucket("learnpack")
|
38
|
+
|
39
|
+
async function listFilesWithPrefix(prefix: string) {
|
40
|
+
const [files] = await bucket.getFiles({ prefix })
|
41
|
+
return files
|
42
|
+
}
|
43
|
+
|
44
|
+
export default class ServeCommand extends SessionCommand {
|
45
|
+
static description = "Runs a small server to build tutorials"
|
46
|
+
|
47
|
+
static flags = {
|
48
|
+
...SessionCommand.flags,
|
49
|
+
port: flags.string({ char: "p", description: "server port" }),
|
50
|
+
host: flags.string({ char: "h", description: "server host" }),
|
51
|
+
debug: flags.boolean({
|
52
|
+
char: "d",
|
53
|
+
description: "debugger mode for more verbage",
|
54
|
+
default: false,
|
55
|
+
}),
|
56
|
+
}
|
57
|
+
|
58
|
+
async init() {
|
59
|
+
const { flags } = this.parse(ServeCommand)
|
60
|
+
console.log("Initializing serve command")
|
61
|
+
}
|
62
|
+
|
63
|
+
async run() {
|
64
|
+
const app = express()
|
65
|
+
const PORT = 3000
|
66
|
+
|
67
|
+
const distPath = path.resolve(__dirname, "../creatorDist")
|
68
|
+
|
69
|
+
// Servir archivos estáticos
|
70
|
+
// app.use(express.static(distPath))
|
71
|
+
app.use(express.json())
|
72
|
+
app.use(cors())
|
73
|
+
|
74
|
+
const appPath = path.resolve(__dirname, "../ui/_app")
|
75
|
+
const tarPath = path.resolve(__dirname, "../ui/app.tar.gz")
|
76
|
+
if (fs.existsSync(appPath)) {
|
77
|
+
fs.rmSync(appPath, { recursive: true })
|
78
|
+
}
|
79
|
+
|
80
|
+
if (fs.existsSync(tarPath)) {
|
81
|
+
fs.rmSync(tarPath)
|
82
|
+
}
|
83
|
+
|
84
|
+
await downloadEditor("5.0.0", `${__dirname}/../ui/app.tar.gz`)
|
85
|
+
|
86
|
+
await decompress(
|
87
|
+
`${__dirname}/../ui/app.tar.gz`,
|
88
|
+
`${__dirname}/../ui/_app/`
|
89
|
+
)
|
90
|
+
|
91
|
+
const localAppPath = path.resolve(__dirname, "../ui/_app")
|
92
|
+
// app.use(express.static(localAppPath))
|
93
|
+
|
94
|
+
app.post("/upload", async (req, res) => {
|
95
|
+
const { content, destination } = req.body
|
96
|
+
// console.log("UPLOAD", content, destination)
|
97
|
+
|
98
|
+
if (!content || !destination) {
|
99
|
+
return res.status(400).send("Missing content or destination")
|
100
|
+
}
|
101
|
+
|
102
|
+
if (!bucket) {
|
103
|
+
return res.status(500).send("Upload failed")
|
104
|
+
}
|
105
|
+
|
106
|
+
const buffer = Buffer.from(content, "utf-8")
|
107
|
+
const file = bucket.file(destination)
|
108
|
+
|
109
|
+
const stream = file.createWriteStream({
|
110
|
+
resumable: false,
|
111
|
+
contentType: "text/plain",
|
112
|
+
})
|
113
|
+
|
114
|
+
stream.on("error", err => {
|
115
|
+
console.error("❌ Error uploading:", err)
|
116
|
+
res.status(500).send("Upload failed")
|
117
|
+
})
|
118
|
+
|
119
|
+
stream.on("finish", () => {
|
120
|
+
console.log(`✅ Uploaded to: ${file.name}`)
|
121
|
+
res.send("File uploaded successfully")
|
122
|
+
})
|
123
|
+
|
124
|
+
stream.end(buffer)
|
125
|
+
})
|
126
|
+
|
127
|
+
app.get("/", async (req, res) => {
|
128
|
+
// The the ui/_app/index.html
|
129
|
+
console.log("GET /")
|
130
|
+
|
131
|
+
const file = path.resolve(__dirname, "../ui/_app/index.html")
|
132
|
+
res.sendFile(file)
|
133
|
+
})
|
134
|
+
|
135
|
+
app.get("/config", async (req, res) => {
|
136
|
+
const courseSlug = req.query.slug
|
137
|
+
const files = await listFilesWithPrefix(`courses/${courseSlug}`)
|
138
|
+
|
139
|
+
const learnJson = files.find(file => file.name.endsWith("learn.json"))
|
140
|
+
const learnJsonContent = await learnJson?.download()
|
141
|
+
const learnJsonParsed = JSON.parse(learnJsonContent?.toString() || "{}")
|
142
|
+
|
143
|
+
const exerciseMap: ExerciseMap = {}
|
144
|
+
|
145
|
+
// Agrupar archivos por ejercicio
|
146
|
+
for (const file of files) {
|
147
|
+
const pathParts = file.name.split("/")
|
148
|
+
const isExercise = pathParts.includes("exercises")
|
149
|
+
if (!isExercise)
|
150
|
+
continue
|
151
|
+
|
152
|
+
const slug = pathParts[pathParts.indexOf("exercises") + 1]
|
153
|
+
if (!exerciseMap[slug]) {
|
154
|
+
exerciseMap[slug] = {
|
155
|
+
title: slug,
|
156
|
+
slug: slug,
|
157
|
+
graded: false,
|
158
|
+
files: [],
|
159
|
+
translations: {},
|
160
|
+
}
|
161
|
+
}
|
162
|
+
|
163
|
+
const fileName = pathParts.at(-1)
|
164
|
+
|
165
|
+
// Traducciones
|
166
|
+
const readmeMatch = fileName?.match(/^readme(?:\.([a-z]{2}))?\.md$/i)
|
167
|
+
if (readmeMatch) {
|
168
|
+
const lang = readmeMatch[1] || "us"
|
169
|
+
exerciseMap[slug].translations[lang] = fileName || ""
|
170
|
+
} else {
|
171
|
+
exerciseMap[slug].files.push(fileName || "")
|
172
|
+
}
|
173
|
+
}
|
174
|
+
|
175
|
+
const exercises = Object.values(exerciseMap).map((ex, index) => ({
|
176
|
+
...ex,
|
177
|
+
position: index,
|
178
|
+
}))
|
179
|
+
|
180
|
+
res.set("X-Creator-Web", "true")
|
181
|
+
res.set("Access-Control-Expose-Headers", "X-Creator-Web")
|
182
|
+
|
183
|
+
res.send({
|
184
|
+
config: { ...learnJsonParsed, title: { us: courseSlug } },
|
185
|
+
exercises,
|
186
|
+
})
|
187
|
+
})
|
188
|
+
|
189
|
+
app.get("/exercise/:slug/readme", async (req, res) => {
|
190
|
+
console.log("GET /exercise/:slug/readme")
|
191
|
+
|
192
|
+
const { slug } = req.params
|
193
|
+
const courseSlug = req.query.slug
|
194
|
+
const lang = req.query.lang || "us"
|
195
|
+
|
196
|
+
const basePath = `courses/${courseSlug}/exercises/${slug}/`
|
197
|
+
const filename = lang === "us" ? "README.md" : `README.${lang}.md`
|
198
|
+
|
199
|
+
const file = bucket.file(basePath + filename)
|
200
|
+
|
201
|
+
let contentBuffer
|
202
|
+
|
203
|
+
try {
|
204
|
+
contentBuffer = await file.download()
|
205
|
+
} catch {
|
206
|
+
if (lang !== "us") {
|
207
|
+
console.warn(`No README for lang '${lang}', falling back to 'us'`)
|
208
|
+
const fallbackFile = bucket.file(basePath + "README.md")
|
209
|
+
contentBuffer = await fallbackFile.download()
|
210
|
+
} else {
|
211
|
+
return res.status(404).json({ error: "README not found" })
|
212
|
+
}
|
213
|
+
}
|
214
|
+
|
215
|
+
const { attributes, body } = frontMatter(contentBuffer[0].toString())
|
216
|
+
|
217
|
+
res.send({ attributes, body })
|
218
|
+
})
|
219
|
+
|
220
|
+
app.put(
|
221
|
+
"/exercise/:slug/file/:fileName",
|
222
|
+
express.text(),
|
223
|
+
async (req, res) => {
|
224
|
+
const { slug, fileName } = req.params
|
225
|
+
const query = req.query
|
226
|
+
console.log(`PUT /exercise/${slug}/file/${fileName}`)
|
227
|
+
|
228
|
+
const courseSlug = query.slug
|
229
|
+
console.log("COURSE SLUG", courseSlug)
|
230
|
+
|
231
|
+
// Update the file in the bucket
|
232
|
+
const file = await bucket.file(
|
233
|
+
"courses/" + courseSlug + "/exercises/" + slug + "/" + fileName
|
234
|
+
)
|
235
|
+
await file.save(req.body)
|
236
|
+
const created = await file.exists()
|
237
|
+
console.log("File updated", created)
|
238
|
+
res.send({
|
239
|
+
message: "File updated",
|
240
|
+
created,
|
241
|
+
})
|
242
|
+
}
|
243
|
+
)
|
244
|
+
app.post("/exercise/:slug/create", async (req, res) => {
|
245
|
+
console.log("POST /exercise/:slug/create")
|
246
|
+
const query = req.query
|
247
|
+
const { title, readme, language } = req.body
|
248
|
+
|
249
|
+
if (!title || !readme) {
|
250
|
+
return res
|
251
|
+
.status(400)
|
252
|
+
.json({ error: "Missing title or readme content" })
|
253
|
+
}
|
254
|
+
|
255
|
+
const courseSlug = query.slug
|
256
|
+
|
257
|
+
const fileName = `courses/${courseSlug}/exercises/${title}/README${
|
258
|
+
language === "us" || language === "en" ? "" : `.${language}`
|
259
|
+
}.md`
|
260
|
+
const file = bucket.file(fileName)
|
261
|
+
await file.save(readme)
|
262
|
+
const created = await file.exists()
|
263
|
+
res.send({
|
264
|
+
message: "File updated",
|
265
|
+
created,
|
266
|
+
})
|
267
|
+
})
|
268
|
+
|
269
|
+
app.put("/actions/rename", async (req, res) => {
|
270
|
+
console.log("PUT /actions/rename")
|
271
|
+
const { slug, newSlug } = req.body
|
272
|
+
const query = req.query
|
273
|
+
const courseSlug = query.slug
|
274
|
+
const filePrefix = `courses/${courseSlug}/exercises/${slug}/`
|
275
|
+
const [files] = await bucket.getFiles({ prefix: filePrefix })
|
276
|
+
for (const file of files) {
|
277
|
+
const newFileName = file.name.replace(slug, newSlug)
|
278
|
+
// eslint-disable-next-line no-await-in-loop
|
279
|
+
await file.rename(newFileName)
|
280
|
+
}
|
281
|
+
|
282
|
+
res.send({ message: "Files renamed" })
|
283
|
+
})
|
284
|
+
|
285
|
+
app.post("/actions/translate", express.json(), async (req, res) => {
|
286
|
+
console.log("POST /actions/translate")
|
287
|
+
const { exerciseSlugs, languages, rigoToken } = req.body
|
288
|
+
const query = req.query
|
289
|
+
const courseSlug = query.slug
|
290
|
+
|
291
|
+
console.log("EXERCISE SLUGS", exerciseSlugs)
|
292
|
+
console.log("LANGUAGES", languages)
|
293
|
+
console.log("RIGO TOKEN", rigoToken)
|
294
|
+
|
295
|
+
if (!rigoToken) {
|
296
|
+
return res.status(400).json({ error: "RigoToken not found" })
|
297
|
+
}
|
298
|
+
|
299
|
+
const languagesToTranslate: string[] = languages.split(",")
|
300
|
+
|
301
|
+
try {
|
302
|
+
await Promise.all(
|
303
|
+
exerciseSlugs.map(async (slug: string) => {
|
304
|
+
const readmePath = `courses/${courseSlug}/exercises/${slug}/README.md`
|
305
|
+
const readme = await bucket.file(readmePath).download()
|
306
|
+
await Promise.all(
|
307
|
+
languagesToTranslate.map(async (language: string) => {
|
308
|
+
const response = await translateExercise(rigoToken, {
|
309
|
+
text_to_translate: readme.toString(),
|
310
|
+
output_language: language,
|
311
|
+
exercise_slug: slug,
|
312
|
+
})
|
313
|
+
|
314
|
+
const translatedReadme = await bucket.file(
|
315
|
+
`courses/${courseSlug}/exercises/${slug}/README.${response.parsed.output_language_code}.md`
|
316
|
+
)
|
317
|
+
await translatedReadme.save(response.parsed.translation)
|
318
|
+
})
|
319
|
+
)
|
320
|
+
})
|
321
|
+
)
|
322
|
+
|
323
|
+
return res.status(200).json({ message: "Translated exercises" })
|
324
|
+
} catch (error) {
|
325
|
+
console.log(error, "ERROR")
|
326
|
+
return res.status(400).json({ error: (error as Error).message })
|
327
|
+
}
|
328
|
+
})
|
329
|
+
|
330
|
+
app.delete("/exercise/:slug/delete", async (req, res) => {
|
331
|
+
const { slug } = req.params
|
332
|
+
const query = req.query
|
333
|
+
const courseSlug = query.slug
|
334
|
+
const filePrefix = `courses/${courseSlug}/exercises/${slug}/`
|
335
|
+
const [files] = await bucket.getFiles({ prefix: filePrefix })
|
336
|
+
for (const file of files) {
|
337
|
+
// eslint-disable-next-line no-await-in-loop
|
338
|
+
await file.delete()
|
339
|
+
}
|
340
|
+
|
341
|
+
res.send({ message: "Files deleted" })
|
342
|
+
})
|
343
|
+
|
344
|
+
app.get("/assets/:file", (req, res) => {
|
345
|
+
const file = path.join(localAppPath, req.params.file)
|
346
|
+
res.sendFile(file)
|
347
|
+
})
|
348
|
+
// Enviar index.html para todas las rutas
|
349
|
+
app.get("/creator", (req, res) => {
|
350
|
+
res.sendFile(path.join(distPath, "index.html"))
|
351
|
+
})
|
352
|
+
app.get("/creator/syllabus", (req, res) => {
|
353
|
+
res.sendFile(path.join(distPath, "index.html"))
|
354
|
+
})
|
355
|
+
|
356
|
+
app.get("/creator/:file", (req, res) => {
|
357
|
+
console.log("GET /creator/:file", req.params.file)
|
358
|
+
const file = path.join(distPath, req.params.file)
|
359
|
+
res.sendFile(file)
|
360
|
+
})
|
361
|
+
|
362
|
+
app.get("/creator/assets/:file", (req, res) => {
|
363
|
+
const file = path.join(distPath, "assets", req.params.file)
|
364
|
+
res.sendFile(file)
|
365
|
+
})
|
366
|
+
|
367
|
+
app.listen(PORT, () => {
|
368
|
+
console.log(`🚀 Creator UI server running at http://localhost:${PORT}`)
|
369
|
+
})
|
370
|
+
}
|
371
|
+
}
|
@@ -0,0 +1,54 @@
|
|
1
|
+
# React + TypeScript + Vite
|
2
|
+
|
3
|
+
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
4
|
+
|
5
|
+
Currently, two official plugins are available:
|
6
|
+
|
7
|
+
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh
|
8
|
+
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
9
|
+
|
10
|
+
## Expanding the ESLint configuration
|
11
|
+
|
12
|
+
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
13
|
+
|
14
|
+
```js
|
15
|
+
export default tseslint.config({
|
16
|
+
extends: [
|
17
|
+
// Remove ...tseslint.configs.recommended and replace with this
|
18
|
+
...tseslint.configs.recommendedTypeChecked,
|
19
|
+
// Alternatively, use this for stricter rules
|
20
|
+
...tseslint.configs.strictTypeChecked,
|
21
|
+
// Optionally, add this for stylistic rules
|
22
|
+
...tseslint.configs.stylisticTypeChecked,
|
23
|
+
],
|
24
|
+
languageOptions: {
|
25
|
+
// other options...
|
26
|
+
parserOptions: {
|
27
|
+
project: ["./tsconfig.node.json", "./tsconfig.app.json"],
|
28
|
+
tsconfigRootDir: import.meta.dirname,
|
29
|
+
},
|
30
|
+
},
|
31
|
+
})
|
32
|
+
```
|
33
|
+
|
34
|
+
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
35
|
+
|
36
|
+
```js
|
37
|
+
// eslint.config.js
|
38
|
+
import reactX from "eslint-plugin-react-x"
|
39
|
+
import reactDom from "eslint-plugin-react-dom"
|
40
|
+
|
41
|
+
export default tseslint.config({
|
42
|
+
plugins: {
|
43
|
+
// Add the react-x and react-dom plugins
|
44
|
+
"react-x": reactX,
|
45
|
+
"react-dom": reactDom,
|
46
|
+
},
|
47
|
+
rules: {
|
48
|
+
// other rules...
|
49
|
+
// Enable its recommended typescript rules
|
50
|
+
...reactX.configs["recommended-typescript"].rules,
|
51
|
+
...reactDom.configs.recommended.rules,
|
52
|
+
},
|
53
|
+
})
|
54
|
+
```
|
@@ -0,0 +1,28 @@
|
|
1
|
+
import js from "@eslint/js"
|
2
|
+
import globals from "globals"
|
3
|
+
import reactHooks from "eslint-plugin-react-hooks"
|
4
|
+
import reactRefresh from "eslint-plugin-react-refresh"
|
5
|
+
import tseslint from "typescript-eslint"
|
6
|
+
|
7
|
+
export default tseslint.config(
|
8
|
+
{ ignores: ["dist"] },
|
9
|
+
{
|
10
|
+
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
11
|
+
files: ["**/*.{ts,tsx}"],
|
12
|
+
languageOptions: {
|
13
|
+
ecmaVersion: 2020,
|
14
|
+
globals: globals.browser,
|
15
|
+
},
|
16
|
+
plugins: {
|
17
|
+
"react-hooks": reactHooks,
|
18
|
+
"react-refresh": reactRefresh,
|
19
|
+
},
|
20
|
+
rules: {
|
21
|
+
...reactHooks.configs.recommended.rules,
|
22
|
+
"react-refresh/only-export-components": [
|
23
|
+
"warn",
|
24
|
+
{ allowConstantExport: true },
|
25
|
+
],
|
26
|
+
},
|
27
|
+
}
|
28
|
+
)
|
@@ -0,0 +1,13 @@
|
|
1
|
+
<!doctype html>
|
2
|
+
<html lang="en">
|
3
|
+
<head>
|
4
|
+
<meta charset="UTF-8" />
|
5
|
+
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
6
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
7
|
+
<title>Vite + React + TS</title>
|
8
|
+
</head>
|
9
|
+
<body>
|
10
|
+
<div id="root"></div>
|
11
|
+
<script type="module" src="/src/main.tsx"></script>
|
12
|
+
</body>
|
13
|
+
</html>
|