feat: allow loading course data from github gists (#2073)

This commit is contained in:
Daniel Kantor 2022-03-24 09:22:05 +01:00 committed by GitHub
parent b60b9d0b0f
commit e774fe401a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
29 changed files with 1663 additions and 162 deletions

View File

@ -15,6 +15,7 @@
"js-levenshtein": "1.1.6"
},
"devDependencies": {
"@types/js-levenshtein": "1.1.1"
"@types/js-levenshtein": "1.1.1",
"typescript": "^4.6.2"
}
}

View File

@ -0,0 +1,13 @@
Feature: Previewing courses using GitHub Gists
JSON courses can be uploaded to GitHub Gists. The user can then preview their course without having to
deploy the course or set up LibreLingo on their computer.
#
# Scenario: Opening a skill from a GitHub Gist directly
# Given I open "/course/preview/skill/gist?skillName=helloworld&gistId=2428349a05d81f96b2311c2749ea5c6d"
# Then I see a "Skip" button
#
# Scenario: Opening a skill introduction from a GitHub Gist directly
# Given I open "/course/preview/skill/gist/introduction?skillName=animals&gistId=2428349a05d81f96b2311c2749ea5c6d"
# Then I see a "Practice Animals" button
#

View File

@ -36,12 +36,12 @@
"github-fork-ribbon-css": "0.2.3",
"hotkeys-js": "3.8.7",
"howler": "2.2.3",
"isomorphic-fetch": "^3.0.0",
"js-cookie": "3.0.1",
"js-levenshtein": "1.1.6",
"lluis": "0.0.0",
"lodash.shuffle": "4.2.0",
"lodash.uniq": "4.5.0",
"p-memoize": "6.0.1",
"polka": "next",
"pouchdb": "7.2.2",
"showdown": "^2.0.3",
@ -80,6 +80,7 @@
"jest": "27.4.7",
"json-update": "5.3.0",
"mini-css-extract-plugin": "1.6.2",
"msw": "^0.39.2",
"node-sass": "5.0.0",
"npm-run-all": "4.1.5",
"nyc": "15.1.0",

View File

@ -14,6 +14,7 @@ import {
faSpinner,
} from "@fortawesome/free-solid-svg-icons"
import { faTwitter } from "@fortawesome/free-brands-svg-icons"
import { worker } from "./mocks/browser"
library.add(faVolumeUp)
library.add(faCheckSquare)
@ -27,6 +28,9 @@ library.add(faHeart)
library.add(faSpinner)
dom.watch()
// Intercept certain HTTP requests
worker.start()
sapper.start({
target: document.querySelector("#sapper"),
})

View File

@ -14,6 +14,7 @@
import db from "../db/db"
import isBrowser from "../utils/isBrowser"
export let rawChallenges
export let languageName
export let languageCode

View File

@ -1,11 +1,3 @@
<script lang="typescript" context="module">
import loadMarkdownModule from "../utils/loadMarkdownModule"
export async function getMarkDownData(markdownModule) {
return loadMarkdownModule(markdownModule)
}
</script>
<script lang="typescript">
import NavBar from "../components/NavBar.svelte"
import Content from "lluis/Content.svelte"

View File

@ -1,6 +1,6 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`get_course returns correct course data 1`] = `
exports[`get_course returns correct course data: test course data 1`] = `
Object {
"courseName": "test",
"languageCode": "test",
@ -1041,13 +1041,3 @@ perro",
],
}
`;
exports[`get_skill_introduction returns correct course data 1`] = `
Object {
"courseName": "test",
"practiceHref": "animals",
"readmeHTML": undefined,
"skillName": "animals",
"title": "Animals",
}
`;

View File

@ -2,7 +2,30 @@ import { get_course, get_skill_data, get_skill_introduction } from "../index"
describe("get_course", () => {
it("returns correct course data", async () => {
expect(await get_course({ courseName: "test" })).toMatchSnapshot()
expect(await get_course({ courseName: "test" })).toMatchSnapshot(
"test course data"
)
})
it("returns error when non-existent github gist is used", async () => {
expect(
get_course({
courseName: "test",
gistId:
"db0545fc1ace67dd8c67d3bcae571b4442161060cd3cfc3890c55e351ec79245",
})
).rejects.toThrowErrorMatchingInlineSnapshot(
`"Could not load gist with Id \\"db0545fc1ace67dd8c67d3bcae571b4442161060cd3cfc3890c55e351ec79245\\". TypeError: Cannot convert undefined or null to object"`
)
})
it("returns correct data when existing gist id is supplied", async () => {
const courseData = await get_course({
courseName: "test",
gistId: "2428349a05d81f96b2311c2749ea5c6d",
})
expect(courseData).toEqual(await get_course({ courseName: "test" }))
})
})
@ -12,12 +35,66 @@ describe("get_skill_data", () => {
await get_skill_data({ courseName: "test", skillName: "animals" })
).toMatchSnapshot()
})
it("returns error when non-existent github gist is used", async () => {
expect(
get_skill_data({
courseName: "test",
skillName: "animals",
gistId:
"db0545fc1ace67dd8c67d3bcae571b4442161060cd3cfc3890c55e351ec79245",
})
).rejects.toThrowErrorMatchingInlineSnapshot(
`"Could not load gist with Id \\"db0545fc1ace67dd8c67d3bcae571b4442161060cd3cfc3890c55e351ec79245\\". TypeError: Cannot convert undefined or null to object"`
)
})
it("returns correct course data when gist id is supplied", async () => {
expect(
await get_skill_data({
courseName: "testGist",
skillName: "animals",
gistId: "2428349a05d81f96b2311c2749ea5c6d",
})
).toEqual({
...(await get_skill_data({
courseName: "test",
skillName: "animals",
})),
courseURL: "/course/testGist",
})
})
})
describe("get_skill_introduction", () => {
it("returns correct course data", async () => {
expect(
await get_skill_introduction({ courseName: "test", skillName: "animals" })
).toMatchSnapshot()
).toMatchInlineSnapshot(`
Object {
"courseName": "test",
"practiceHref": "animals",
"readmeHTML": undefined,
"skillName": "animals",
"title": "Animals",
}
`)
})
it("returns correct course data", async () => {
expect(
await get_skill_introduction({
courseName: "testGist",
skillName: "animals",
gistId: "2428349a05d81f96b2311c2749ea5c6d",
})
).toEqual({
...(await get_skill_introduction({
courseName: "test",
skillName: "animals",
})),
readmeHTML: "<p>hello world</p>",
courseName: "testGist",
})
})
})

View File

@ -1,4 +1,6 @@
import loadMarkdownModule from "../utils/loadMarkdownModule"
import fetch from "isomorphic-fetch"
import parseMarkdown from "../utils/parseMarkdown"
import { baseURL } from "../../../../config/gists.json"
export type SkillDataType = {
id: string
@ -23,18 +25,14 @@ export type CourseDataType = {
specialCharacters: string[]
}
export const get_course = async ({
courseName,
}: {
courseName: string
}): Promise<CourseDataType> => {
const formatCourseData = (rawCourseData, { courseName }) => {
const {
modules,
languageName,
repositoryURL,
languageCode,
specialCharacters,
} = require(`../courses/${courseName}/courseData.json`) // eslint-disable-line @typescript-eslint/no-var-requires
} = rawCourseData
return {
courseName,
@ -46,17 +44,56 @@ export const get_course = async ({
}
}
export const get_skill_data = async ({
type RawGistFileType = {
content: string
}
const fetchGistFiles = async (gistId) => {
// get the data from a Github gist served through a CORS proxy
try {
const toAWait = fetch(`${baseURL}/${gistId}`)
const rawResponse = await toAWait
const response = await rawResponse.json()
const gistFiles = Object.fromEntries(
Object.entries(response.files).map(
([filename, value]: [string, RawGistFileType]) => [
filename.replace("librelingo___", "").replace("___", "/"),
filename.endsWith(".json")
? JSON.parse(value?.content)
: value?.content,
]
)
)
return gistFiles
} catch (error) {
throw new Error(`Could not load gist with Id "${gistId}". ${error}`)
}
}
export const get_course = async ({
courseName,
skillName,
gistId = null,
}: {
courseName: string
skillName: string
}) => {
gistId?: string
}): Promise<CourseDataType> => {
if (gistId !== null) {
const files = await fetchGistFiles(gistId)
return formatCourseData(files["courseData.json"], { courseName })
}
const rawCourseData = require(`../courses/${courseName}/courseData.json`) // eslint-disable-line @typescript-eslint/no-var-requires
return formatCourseData(rawCourseData, { courseName })
}
const formatSkilldata = async (
skillData,
{ courseName, skillName, gistId }
) => {
const { languageName, languageCode, specialCharacters, repositoryURL } =
await get_course({ courseName })
// eslint-disable-next-line @typescript-eslint/no-var-requires
const skillData = require(`../courses/${courseName}/challenges/${skillName}.json`)
await get_course({ courseName, gistId })
const rawChallenges = skillData.challenges
const challengesPerLevel = skillData.challenges.length / skillData.levels
@ -75,29 +112,82 @@ export const get_skill_data = async ({
}
}
export const get_skill_introduction = async ({
export const get_skill_data = async ({
courseName,
skillName,
gistId = null,
}: {
courseName: string
skillName: string
gistId?: string
}) => {
const { modules } = await get_course({ courseName })
if (gistId !== null) {
const files = await fetchGistFiles(gistId)
return await formatSkilldata(files[`challenges/${skillName}.json`], {
courseName,
skillName,
gistId,
})
}
// eslint-disable-next-line @typescript-eslint/no-var-requires
const skillData = require(`../courses/${courseName}/challenges/${skillName}.json`)
return await formatSkilldata(skillData, { courseName, skillName, gistId })
}
const formatSkillIntroduction = async (
skill,
{ skillName, courseName, rawMarkdown }
) => {
return {
skillName,
courseName,
title: skill.title,
practiceHref: skill.practiceHref,
readmeHTML: parseMarkdown(rawMarkdown),
}
}
export const get_skill_introduction = async ({
courseName,
skillName,
gistId,
}: {
courseName: string
skillName: string
gistId?: string
}) => {
const { modules } = await get_course({ courseName, gistId })
for (const module of modules) {
for (const skill of module.skills) {
if (skill.practiceHref === skillName) {
// eslint-disable-next-line @typescript-eslint/no-var-requires
const markdownModule = require(`../courses/${courseName}/introduction/${skill.introduction}`)
if (gistId) {
const files = await fetchGistFiles(gistId)
return {
return formatSkillIntroduction(skill, {
skillName,
courseName,
rawMarkdown: files[`introduction/${skill.introduction}`],
})
}
const {
default: rawMarkdown,
// eslint-disable-next-line @typescript-eslint/no-var-requires
} = require(`../courses/${courseName}/introduction/${skill.introduction}`)
return formatSkillIntroduction(skill, {
skillName,
courseName,
title: skill.title,
practiceHref: skill.practiceHref,
readmeHTML: loadMarkdownModule(markdownModule),
}
rawMarkdown,
})
}
}
}
throw new Error(
`Could not find skill with name "${skillName}" in course "${courseName}".`
)
}

View File

@ -0,0 +1,87 @@
{
"url": "https://api.github.com/gists/2428349a05d81f96b2311c2749ea5c6d",
"forks_url": "https://api.github.com/gists/2428349a05d81f96b2311c2749ea5c6d/forks",
"commits_url": "https://api.github.com/gists/2428349a05d81f96b2311c2749ea5c6d/commits",
"id": "2428349a05d81f96b2311c2749ea5c6d",
"node_id": "G_kwDOADiISNoAIDI0MjgzNDlhMDVkODFmOTZiMjMxMWMyNzQ5ZWE1YzZk",
"git_pull_url": "https://gist.github.com/2428349a05d81f96b2311c2749ea5c6d.git",
"git_push_url": "https://gist.github.com/2428349a05d81f96b2311c2749ea5c6d.git",
"html_url": "https://gist.github.com/2428349a05d81f96b2311c2749ea5c6d",
"files": {
"file_one.md": {
"filename": "file_one.md",
"type": "text/markdown",
"language": "Markdown",
"raw_url": "https://gist.githubusercontent.com/kantord/2428349a05d81f96b2311c2749ea5c6d/raw/95d09f2b10159347eece71399a7e2e907ea3df4f/file_one.md",
"size": 11,
"truncated": false,
"content": "hello world"
},
"file_two.yaml": {
"filename": "file_two.yaml",
"type": "text/x-yaml",
"language": "YAML",
"raw_url": "https://gist.githubusercontent.com/kantord/2428349a05d81f96b2311c2749ea5c6d/raw/c73a4f3fbf0fb29f1c84878cd1d0c56ca492d858/file_two.yaml",
"size": 9,
"truncated": false,
"content": "hello: 42"
}
},
"public": false,
"created_at": "2022-03-15T17:07:21Z",
"updated_at": "2022-03-15T17:07:21Z",
"description": "",
"comments": 0,
"user": null,
"comments_url": "https://api.github.com/gists/2428349a05d81f96b2311c2749ea5c6d/comments",
"owner": {
"login": "kantord",
"id": 3704904,
"node_id": "MDQ6VXNlcjM3MDQ5MDQ=",
"avatar_url": "https://avatars.githubusercontent.com/u/3704904?v=4",
"gravatar_id": "",
"url": "https://api.github.com/users/kantord",
"html_url": "https://github.com/kantord",
"followers_url": "https://api.github.com/users/kantord/followers",
"following_url": "https://api.github.com/users/kantord/following{/other_user}",
"gists_url": "https://api.github.com/users/kantord/gists{/gist_id}",
"starred_url": "https://api.github.com/users/kantord/starred{/owner}{/repo}",
"subscriptions_url": "https://api.github.com/users/kantord/subscriptions",
"organizations_url": "https://api.github.com/users/kantord/orgs",
"repos_url": "https://api.github.com/users/kantord/repos",
"events_url": "https://api.github.com/users/kantord/events{/privacy}",
"received_events_url": "https://api.github.com/users/kantord/received_events",
"type": "User",
"site_admin": false
},
"forks": [],
"history": [
{
"user": {
"login": "kantord",
"id": 3704904,
"node_id": "MDQ6VXNlcjM3MDQ5MDQ=",
"avatar_url": "https://avatars.githubusercontent.com/u/3704904?v=4",
"gravatar_id": "",
"url": "https://api.github.com/users/kantord",
"html_url": "https://github.com/kantord",
"followers_url": "https://api.github.com/users/kantord/followers",
"following_url": "https://api.github.com/users/kantord/following{/other_user}",
"gists_url": "https://api.github.com/users/kantord/gists{/gist_id}",
"starred_url": "https://api.github.com/users/kantord/starred{/owner}{/repo}",
"subscriptions_url": "https://api.github.com/users/kantord/subscriptions",
"organizations_url": "https://api.github.com/users/kantord/orgs",
"repos_url": "https://api.github.com/users/kantord/repos",
"events_url": "https://api.github.com/users/kantord/events{/privacy}",
"received_events_url": "https://api.github.com/users/kantord/received_events",
"type": "User",
"site_admin": false
},
"version": "876dfc5d3cc237282e3022a2f97eaad0ab32aa69",
"committed_at": "2022-03-15T17:07:21Z",
"change_status": { "total": 2, "additions": 2, "deletions": 0 },
"url": "https://api.github.com/gists/2428349a05d81f96b2311c2749ea5c6d/876dfc5d3cc237282e3022a2f97eaad0ab32aa69"
}
],
"truncated": false
}

View File

@ -0,0 +1,5 @@
import { setupWorker } from "msw"
import { rest } from "msw"
import { getHandlers } from "./handlers"
export const worker = setupWorker(...getHandlers(rest, { mockAll: false }))

View File

@ -0,0 +1,72 @@
import ExampleGistResponse from "./__fixtures__/example-gist-response.json"
import { default as ExampleCourseDataFile } from "../courses/test/courseData.json"
import { default as TestCourseAnimalsSkillFile } from "../courses/test/challenges/animals.json"
import gistsConfig from "../../../../config/gists.json"
const FAKE_GIST_FILE = {
filename: "hello",
language: "json",
raw_url:
"https://gist.githubusercontent.com/asdf/2428349a05d81f96b2311c2749ea5c6d/raw/f9f8b9f9f9f9f9f9f9f9f9f9f9f9f9f9f9f9f9f/librelingo___courseData.ts",
size: 5,
type: "text/plain",
truncated: false,
content: "",
}
export const getHandlers = (rest, { mockAll } = { mockAll: true }) => {
const fallbackMock = mockAll
? [
rest.get("*", (req) => {
throw new Error(
`Unhandled request: ${req.url}. All requests must be mocked in ${__filename}`
)
}),
]
: []
return [
rest.get(
`${gistsConfig.baseURL}/db0545fc1ace67dd8c67d3bcae571b4442161060cd3cfc3890c55e351ec79245`,
(req, res, ctx) => {
return res(ctx.status(404), ctx.json({ error: "does not exist" }))
}
),
rest.get(
`${gistsConfig.baseURL}/2428349a05d81f96b2311c2749ea5c6d`,
(req, res, ctx) => {
return res(
ctx.json({
...ExampleGistResponse,
files: {
...ExampleGistResponse.files,
"librelingo___courseData.json": {
...FAKE_GIST_FILE,
filename: "librelingo___courseData.json",
content: JSON.stringify(ExampleCourseDataFile),
},
"librelingo___challenges___animals.json": {
...FAKE_GIST_FILE,
filename: "librelingo___challenges___animals.json",
content: JSON.stringify(TestCourseAnimalsSkillFile),
},
"librelingo___challenges___helloworld.json": {
...FAKE_GIST_FILE,
filename: "librelingo___challenges___helloworld.json",
content: JSON.stringify(TestCourseAnimalsSkillFile),
},
"librelingo___introduction___animals.md": {
...FAKE_GIST_FILE,
filename: "librelingo___introduction___animals.md",
content: "hello world",
},
},
})
)
}
),
...fallbackMock,
]
}

View File

@ -0,0 +1,5 @@
import { setupServer } from "msw/node"
import { rest } from "msw"
import { getHandlers } from "./handlers"
export const server = setupServer(...getHandlers(rest))

View File

@ -1,17 +1,16 @@
<script lang="typescript" context="module">
import MarkDownPage, {
getMarkDownData,
} from "../components/MarkDownPage.svelte"
import MarkDownPage from "../components/MarkDownPage.svelte"
import parseMarkdown from "../utils/parseMarkdown"
import { _ } from "svelte-i18n"
export async function preload() {
return {
readmeHTML: // eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
(await getMarkDownData(await import("../../../../README.md"))).split(
"<h2>Tech stack</h2>"
)[0],
}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
const { default: rawMarkdown } = await import("../../../../README.md")
return {
readmeHTML: parseMarkdown(rawMarkdown).split("<h2>Tech stack</h2>")[0],
}
}
</script>
@ -20,7 +19,7 @@
</script>
<MarkDownPage
readmeHTML="{readmeHTML}"
{readmeHTML}
title="About LibreLingo"
description="{$_('about.meta.description')}"
description={$_("about.meta.description")}
/>

View File

@ -2,8 +2,27 @@
import { get_skill_data } from "../../../../../course-client"
export async function preload(page) {
const { skillName, courseName } = page.params
return get_skill_data({ skillName, courseName })
const { skillName, courseName } = page.params
if (courseName === "preview") {
const gistId = page.query.gistId
const skillNameFromQuery = page.query.skillName
return {
loading: true,
preview: {
type: skillName,
gistId,
skillName: skillNameFromQuery,
},
}
}
return {
...(await get_skill_data({ skillName, courseName })),
loading: false,
preview: null,
}
}
</script>
@ -12,6 +31,8 @@
import NavBar from "../../../../../components/NavBar.svelte"
import { sortChallengeGroups } from "./_logic"
export let preview = null
export let loading = true
export let rawChallenges
export let languageName: string
export let languageCode: string
@ -23,24 +44,50 @@
export let challengesPerLevel: number
let expectedNumberOfChallenges = Math.max(
4,
Math.round(challengesPerLevel * 1.2)
4,
Math.round(challengesPerLevel * 1.2)
)
// Fetching preview data
if (preview !== null) {
get_skill_data({
gistId: preview.gistId,
skillName: preview.skillName,
courseName: "preview",
}).then((skillData) => {
rawChallenges = skillData.rawChallenges
languageName = skillData.languageName
languageCode = skillData.languageCode
specialCharacters = skillData.specialCharacters
repositoryURL = skillData.repositoryURL
skillName = skillData.skillName
skillId = skillData.skillId
challengesPerLevel = skillData.challengesPerLevel
courseURL = skillData.courseURL
expectedNumberOfChallenges = Math.max(
4,
Math.round(challengesPerLevel * 1.2)
)
loading = false
})
}
</script>
<svelte:head>
<title>LibreLingo - learn {skillName} in {languageName} for free</title>
</svelte:head>
<NavBar repositoryURL="{repositoryURL}" />
<NavBar {repositoryURL} />
<ChallengeScreen
expectedNumberOfChallenges="{expectedNumberOfChallenges}"
skillId="{skillId}"
rawChallenges="{rawChallenges}"
languageName="{languageName}"
languageCode="{languageCode}"
specialCharacters="{specialCharacters}"
sortChallengeGroups="{sortChallengeGroups}"
courseURL="{courseURL}"
/>
{#if !loading}
<ChallengeScreen
{expectedNumberOfChallenges}
{skillId}
{rawChallenges}
{languageName}
{languageCode}
{specialCharacters}
{sortChallengeGroups}
{courseURL}
/>
{/if}

View File

@ -1,39 +1,69 @@
<script lang="typescript" context="module">
import { _ } from "svelte-i18n"
import { get_skill_introduction } from "../../../../../course-client"
export async function preload(page) {
const { courseName, skillName } = page.params
const { courseName, skillName } = page.params
return get_skill_introduction({ courseName, skillName })
if (courseName === "preview") {
const gistId = page.query.gistId
const skillNameFromQuery = page.query.skillName
return {
loading: true,
preview: {
type: skillName,
gistId,
skillName: skillNameFromQuery,
},
}
}
return {
...(await get_skill_introduction({ courseName, skillName })),
loading: false,
preview: null,
}
}
</script>
<script lang="typescript">
import Button from "lluis/Button.svelte"
import { get_skill_introduction } from "../../../../../course-client"
import MarkDownPage from "../../../../../components/MarkDownPage.svelte"
export let preview = null
export let loading = true
export let readmeHTML: string
export let title: string
export let practiceHref: string
export let courseName: string
// Fetching preview data
if (preview !== null) {
const { skillName, gistId } = preview
get_skill_introduction({ courseName: "preview", skillName, gistId }).then(
(skillData) => {
title = skillData.title
readmeHTML = skillData.readmeHTML
practiceHref = skillData.practiceHref
loading = false
}
)
}
</script>
<MarkDownPage
readmeHTML="{readmeHTML}"
title="{title}"
description="{$_('about.meta.description')}"
>
<div>
<Button
style="primary"
href="{`course/${courseName}/skill/${practiceHref}`}"
>Practice {title}</Button
>
</div>
</MarkDownPage>
{#if !loading}
<MarkDownPage {readmeHTML} {title} description={$_("about.meta.description")}>
<div>
<Button
style="primary"
href={`course/${courseName}/skill/${practiceHref}`}
>Practice {title}</Button
>
</div>
</MarkDownPage>
{/if}
<style>
div {

View File

@ -1,20 +1,20 @@
<script lang="typescript" context="module">
export async function preload() {
try {
try {
// eslint-disable-next-line @typescript-eslint/no-var-requires
const fs = require("fs")
// eslint-disable-next-line @typescript-eslint/no-var-requires
const util = require("util")
const readdir = util.promisify(fs.readdir)
const fs = require("fs")
// eslint-disable-next-line @typescript-eslint/no-var-requires
const util = require("util")
const readdir = util.promisify(fs.readdir)
return {
testSkills: (await readdir("./src/courses/test/challenges")).map(
(fname: string) => fname.split(".")[0]
),
}
} catch (error) {
// do nothing
return {
testSkills: (await readdir("./src/courses/test/challenges")).map(
(fname: string) => fname.split(".")[0]
),
}
} catch (error) {
// do nothing
}
}
</script>
@ -46,6 +46,22 @@
</a>
</li>
{/each}
<li>
<a
target="_blank"
href="/course/preview/skill/gist?skillName=helloworld&gistId=2428349a05d81f96b2311c2749ea5c6d"
>
Test skill: <b>GitHub Gist preview of a skill</b>
</a>
</li>
<li>
<a
target="_blank"
href="/course/preview/skill/gist/introduction?gistId=2428349a05d81f96b2311c2749ea5c6d"
>
Test skill: <b>GitHub Gist preview of a skill introduction</b>
</a>
</li>
<li>
<a target="_blank" href="/dev-typography">
<b>Typography</b>

View File

@ -1,16 +1,15 @@
<script lang="typescript" context="module">
import MarkDownPage, {
getMarkDownData,
} from "../components/MarkDownPage.svelte"
import MarkDownPage from "../components/MarkDownPage.svelte"
import parseMarkdown from "../utils/parseMarkdown"
export async function preload() {
return {
readmeHTML: await getMarkDownData(
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
await import("../../../../docs/LICENSE.md")
),
}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
const { default: rawMarkdown } = await import("../../../../docs/LICENSE.md")
return {
readmeHTML: parseMarkdown(rawMarkdown),
}
}
</script>
@ -18,4 +17,4 @@
export let readmeHTML: string
</script>
<MarkDownPage readmeHTML="{readmeHTML}" title="License" />
<MarkDownPage {readmeHTML} title="License" />

View File

@ -1,18 +1,19 @@
<script lang="typescript" context="module">
import MarkDownPage, {
getMarkDownData,
} from "../components/MarkDownPage.svelte"
import MarkDownPage from "../components/MarkDownPage.svelte"
import parseMarkdown from "../utils/parseMarkdown"
export async function preload() {
return {
readmeHTML: await getMarkDownData(
// ignored because TypeScript doesn't seem to recognize Markdown files
// as modules
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
await import("../../../../docs/website_tos.md")
),
}
// ignored because TypeScript doesn't seem to recognize Markdown files
// as modules
const { default: rawMarkdown } = await import(
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
"../../../../docs/website_tos.md"
)
return {
readmeHTML: parseMarkdown(rawMarkdown),
}
}
</script>
@ -20,4 +21,4 @@
export let readmeHTML: string
</script>
<MarkDownPage readmeHTML="{readmeHTML}" title="Terms of Service" />
<MarkDownPage {readmeHTML} title="Terms of Service" />

View File

@ -2,12 +2,20 @@ import sirv from "sirv"
import polka from "polka"
import compression from "compression"
import * as sapper from "@sapper/server"
import { rest } from "msw"
import { setupServer } from "msw/node"
import { getHandlers } from "../src/mocks/handlers"
import "./i18n"
// eslint-disable-next-line no-undef
const { PORT, NODE_ENV } = process.env
const dev = NODE_ENV === "development"
if (dev) {
const server = setupServer(...getHandlers(rest, { mockAll: false }))
server.listen()
}
polka() // You can also use Express
.use(
"/",

View File

@ -0,0 +1,13 @@
import { server } from "./mocks/server"
// eslint-disable-next-line @typescript-eslint/no-unused-vars
import _ from "node-fetch"
// Start the server before all tests.
beforeAll(() => server.listen())
// Reset any handlers that we may add during individual tests,
// so they don't affect other tests.
afterEach(() => server.resetHandlers())
// Stop the server after all tests have run.
afterAll(() => server.close())

View File

@ -1,8 +0,0 @@
import showdown from "showdown"
export default function loadMarkdownModule(module) {
const { default: text } = module
const converter = new showdown.Converter()
const html = converter.makeHtml(text)
return html
}

View File

@ -0,0 +1,7 @@
import showdown from "showdown"
export default function parseMarkdown(rawMarkdown: string) {
const converter = new showdown.Converter()
const html = converter.makeHtml(rawMarkdown)
return html
}

View File

@ -0,0 +1,338 @@
/* eslint-disable */
/* tslint:disable */
/**
* Mock Service Worker (0.39.2).
* @see https://github.com/mswjs/msw
* - Please do NOT modify this file.
* - Please do NOT serve this file on production.
*/
const INTEGRITY_CHECKSUM = '02f4ad4a2797f85668baf196e553d929'
const bypassHeaderName = 'x-msw-bypass'
const activeClientIds = new Set()
self.addEventListener('install', function () {
return self.skipWaiting()
})
self.addEventListener('activate', async function (event) {
return self.clients.claim()
})
self.addEventListener('message', async function (event) {
const clientId = event.source.id
if (!clientId || !self.clients) {
return
}
const client = await self.clients.get(clientId)
if (!client) {
return
}
const allClients = await self.clients.matchAll()
switch (event.data) {
case 'KEEPALIVE_REQUEST': {
sendToClient(client, {
type: 'KEEPALIVE_RESPONSE',
})
break
}
case 'INTEGRITY_CHECK_REQUEST': {
sendToClient(client, {
type: 'INTEGRITY_CHECK_RESPONSE',
payload: INTEGRITY_CHECKSUM,
})
break
}
case 'MOCK_ACTIVATE': {
activeClientIds.add(clientId)
sendToClient(client, {
type: 'MOCKING_ENABLED',
payload: true,
})
break
}
case 'MOCK_DEACTIVATE': {
activeClientIds.delete(clientId)
break
}
case 'CLIENT_CLOSED': {
activeClientIds.delete(clientId)
const remainingClients = allClients.filter((client) => {
return client.id !== clientId
})
// Unregister itself when there are no more clients
if (remainingClients.length === 0) {
self.registration.unregister()
}
break
}
}
})
// Resolve the "main" client for the given event.
// Client that issues a request doesn't necessarily equal the client
// that registered the worker. It's with the latter the worker should
// communicate with during the response resolving phase.
async function resolveMainClient(event) {
const client = await self.clients.get(event.clientId)
if (client.frameType === 'top-level') {
return client
}
const allClients = await self.clients.matchAll()
return allClients
.filter((client) => {
// Get only those clients that are currently visible.
return client.visibilityState === 'visible'
})
.find((client) => {
// Find the client ID that's recorded in the
// set of clients that have registered the worker.
return activeClientIds.has(client.id)
})
}
async function handleRequest(event, requestId) {
const client = await resolveMainClient(event)
const response = await getResponse(event, client, requestId)
// Send back the response clone for the "response:*" life-cycle events.
// Ensure MSW is active and ready to handle the message, otherwise
// this message will pend indefinitely.
if (client && activeClientIds.has(client.id)) {
;(async function () {
const clonedResponse = response.clone()
sendToClient(client, {
type: 'RESPONSE',
payload: {
requestId,
type: clonedResponse.type,
ok: clonedResponse.ok,
status: clonedResponse.status,
statusText: clonedResponse.statusText,
body:
clonedResponse.body === null ? null : await clonedResponse.text(),
headers: serializeHeaders(clonedResponse.headers),
redirected: clonedResponse.redirected,
},
})
})()
}
return response
}
async function getResponse(event, client, requestId) {
const { request } = event
const requestClone = request.clone()
const getOriginalResponse = () => fetch(requestClone)
// Bypass mocking when the request client is not active.
if (!client) {
return getOriginalResponse()
}
// Bypass initial page load requests (i.e. static assets).
// The absence of the immediate/parent client in the map of the active clients
// means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet
// and is not ready to handle requests.
if (!activeClientIds.has(client.id)) {
return await getOriginalResponse()
}
// Bypass requests with the explicit bypass header
if (requestClone.headers.get(bypassHeaderName) === 'true') {
const cleanRequestHeaders = serializeHeaders(requestClone.headers)
// Remove the bypass header to comply with the CORS preflight check.
delete cleanRequestHeaders[bypassHeaderName]
const originalRequest = new Request(requestClone, {
headers: new Headers(cleanRequestHeaders),
})
return fetch(originalRequest)
}
// Send the request to the client-side MSW.
const reqHeaders = serializeHeaders(request.headers)
const body = await request.text()
const clientMessage = await sendToClient(client, {
type: 'REQUEST',
payload: {
id: requestId,
url: request.url,
method: request.method,
headers: reqHeaders,
cache: request.cache,
mode: request.mode,
credentials: request.credentials,
destination: request.destination,
integrity: request.integrity,
redirect: request.redirect,
referrer: request.referrer,
referrerPolicy: request.referrerPolicy,
body,
bodyUsed: request.bodyUsed,
keepalive: request.keepalive,
},
})
switch (clientMessage.type) {
case 'MOCK_SUCCESS': {
return delayPromise(
() => respondWithMock(clientMessage),
clientMessage.payload.delay,
)
}
case 'MOCK_NOT_FOUND': {
return getOriginalResponse()
}
case 'NETWORK_ERROR': {
const { name, message } = clientMessage.payload
const networkError = new Error(message)
networkError.name = name
// Rejecting a request Promise emulates a network error.
throw networkError
}
case 'INTERNAL_ERROR': {
const parsedBody = JSON.parse(clientMessage.payload.body)
console.error(
`\
[MSW] Uncaught exception in the request handler for "%s %s":
${parsedBody.location}
This exception has been gracefully handled as a 500 response, however, it's strongly recommended to resolve this error, as it indicates a mistake in your code. If you wish to mock an error response, please see this guide: https://mswjs.io/docs/recipes/mocking-error-responses\
`,
request.method,
request.url,
)
return respondWithMock(clientMessage)
}
}
return getOriginalResponse()
}
self.addEventListener('fetch', function (event) {
const { request } = event
const accept = request.headers.get('accept') || ''
// Bypass server-sent events.
if (accept.includes('text/event-stream')) {
return
}
// Bypass navigation requests.
if (request.mode === 'navigate') {
return
}
// Opening the DevTools triggers the "only-if-cached" request
// that cannot be handled by the worker. Bypass such requests.
if (request.cache === 'only-if-cached' && request.mode !== 'same-origin') {
return
}
// Bypass all requests when there are no active clients.
// Prevents the self-unregistered worked from handling requests
// after it's been deleted (still remains active until the next reload).
if (activeClientIds.size === 0) {
return
}
const requestId = uuidv4()
return event.respondWith(
handleRequest(event, requestId).catch((error) => {
if (error.name === 'NetworkError') {
console.warn(
'[MSW] Successfully emulated a network error for the "%s %s" request.',
request.method,
request.url,
)
return
}
// At this point, any exception indicates an issue with the original request/response.
console.error(
`\
[MSW] Caught an exception from the "%s %s" request (%s). This is probably not a problem with Mock Service Worker. There is likely an additional logging output above.`,
request.method,
request.url,
`${error.name}: ${error.message}`,
)
}),
)
})
function serializeHeaders(headers) {
const reqHeaders = {}
headers.forEach((value, name) => {
reqHeaders[name] = reqHeaders[name]
? [].concat(reqHeaders[name]).concat(value)
: value
})
return reqHeaders
}
function sendToClient(client, message) {
return new Promise((resolve, reject) => {
const channel = new MessageChannel()
channel.port1.onmessage = (event) => {
if (event.data && event.data.error) {
return reject(event.data.error)
}
resolve(event.data)
}
client.postMessage(JSON.stringify(message), [channel.port2])
})
}
function delayPromise(cb, duration) {
return new Promise((resolve) => {
setTimeout(() => resolve(cb()), duration)
})
}
function respondWithMock(clientMessage) {
return new Response(clientMessage.payload.body, {
...clientMessage.payload,
headers: clientMessage.payload.headers,
})
}
function uuidv4() {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
const r = (Math.random() * 16) | 0
const v = c == 'x' ? r : (r & 0x3) | 0x8
return v.toString(16)
})
}

3
config/gists.json Normal file
View File

@ -0,0 +1,3 @@
{
"baseURL": "https://librelingo.app/gists"
}

View File

@ -1,6 +1,7 @@
# Development
## Why does this project exist?
This project exists to create a beginner-friendly, community-oriented,
free software licensed language learning application. If you want to learn more
about LibreLingo's background, [I recommend reading my article](https://dev.to/kantord/why-i-built-librelingo-280o).
@ -8,7 +9,8 @@ about LibreLingo's background, [I recommend reading my article](https://dev.to/k
## Project structure
### Clickable flow chart
``` mermaid
```mermaid
graph LR
YAML[YAML course] --> LOAD
LOAD[librelingo-yaml-loader] --> EXPORT[librelingo-json-export]
@ -61,13 +63,14 @@ Start the development server:
yarn web dev
```
Now you should be able to see your app on [http://localhost:3000/](http://localhost:3000/)
Now you should be able to see your app on <http://localhost:3000/>
### Exporting a course from YAML
You will need [Poetry](https://python-poetry.org/).
Install dependencies at the top level and for the app:
```sh
poetry install
@ -81,6 +84,7 @@ cd ../..
```
Export a course:
```sh
./scripts/exportYamlCourse.sh <course directory name>
```
@ -95,6 +99,25 @@ This might not be necessary for you in all cases, but it cannot be avoided if yo
Here's a list of the tokens you need to set up. Each of them is a link to a page explaining how to obtain the token:
- [GH_TOKEN](https://docs.github.com/en/github/authenticating-to-github/creating-a-personal-access-token)
- [KNAPSACK_PRO_TEST_SUITE_TOKEN_CYPRESS](https://www.npmjs.com/package/@knapsack-pro/cypress#configuration-steps)
- [PERCY_TOKEN](https://docs.percy.io/docs/environment-variables)
* [GH\_TOKEN](https://docs.github.com/en/github/authenticating-to-github/creating-a-personal-access-token)
* [KNAPSACK\_PRO\_TEST\_SUITE\_TOKEN\_CYPRESS](https://www.npmjs.com/package/@knapsack-pro/cypress#configuration-steps)
* [PERCY\_TOKEN](https://docs.percy.io/docs/environment-variables)
## Testing courses using GitHub gists
It's possible to test courses without them as HTML and deploying them.
One way of doing that is using GitHub gists. You can create a GitHub gist with the course JSON files.
The first step is to export the course as JSON. Then, you have to create a GitHub gist with the
course files.
Keep in mind, that you have to prefix all file names with `librelingo___` and therefore
The first step is to export the course as JSON. Then, you have to create a GitHub gist with the
course files.
Keep in mind, that you have to prefix all file names with `librelingo___` and replace
`/` with `___` in your paths, as GitHub gists don't natively support uploading folders.
So, for example `challenges/animals.json` should be uploaded as the GitHub gist file
`librelingo___challenges___animals.json`.

View File

@ -128,10 +128,12 @@ module.exports = {
// runner: "jest-runner",
// The paths to modules that run some code to configure or set up the testing environment before each test
// setupFiles: [],
// setupFiles: [
// "<rootDir>/apps/web/src/setupTests.ts",
// ],
// A list of paths to modules that run some code to configure or set up the testing framework before each test
// setupFilesAfterEnv: [],
setupFilesAfterEnv: ["<rootDir>/apps/web/src/setupTests.ts"],
// The number of seconds after which a test is considered as slow and reported as such in the results.
// slowTestThreshold: 5,

View File

@ -51,6 +51,7 @@
"eslint-plugin-svelte3": "3.4.0",
"prettier": "2.5.1",
"prettier-plugin-svelte": "^2.6.0",
"remark": "^14.0.2",
"sapper": "0.29.3",
"semantic-release": "18.0.1",
"semantic-release-monorepo": "7.0.5",
@ -87,5 +88,8 @@
"branches": [
"main"
]
},
"msw": {
"workerDirectory": "apps/web/static"
}
}
}

723
yarn.lock

File diff suppressed because it is too large Load Diff