,
163 | }
164 |
165 | #[derive(Serialize, Deserialize, Debug)]
166 | pub struct LastSeed {
167 | #[serde(rename = "projectDashedName")]
168 | pub project_dashed_name: Value,
169 | #[serde(rename = "lessonNumber")]
170 | /// The lesson number last seeded
171 | ///
172 | /// Can be -1, because lessons start at 0, and -1 is used to indicate that no lesson has been seeded
173 | pub lesson_number: i16,
174 | }
175 |
--------------------------------------------------------------------------------
/.freeCodeCamp/plugin/index.js:
--------------------------------------------------------------------------------
1 | import { readFile } from 'fs/promises';
2 | import { freeCodeCampConfig, getState, ROOT } from '../tooling/env.js';
3 | import { CoffeeDown, parseMarkdown } from '../tooling/parser.js';
4 | import { join } from 'path';
5 | import { logover } from '../tooling/logger.js';
6 |
7 | /**
8 | * Project config from `config/projects.json`
9 | * @typedef {Object} Project
10 | * @property {string} id
11 | * @property {string} title
12 | * @property {string} dashedName
13 | * @property {string} description
14 | * @property {boolean} isIntegrated
15 | * @property {boolean} isPublic
16 | * @property {number} currentLesson
17 | * @property {boolean} runTestsOnWatch
18 | * @property {boolean} isResetEnabled
19 | * @property {number} numberOfLessons
20 | * @property {boolean} seedEveryLesson
21 | * @property {boolean} blockingTests
22 | * @property {boolean} breakOnFailure
23 | */
24 |
25 | /**
26 | * @typedef {Object} TestsState
27 | * @property {boolean} passed
28 | * @property {string} testText
29 | * @property {number} testId
30 | * @property {boolean} isLoading
31 | */
32 |
33 | /**
34 | * @typedef {Object} Lesson
35 | * @property {{watch?: string[]; ignore?: string[]} | undefined} meta
36 | * @property {string} description
37 | * @property {[[string, string]]} tests
38 | * @property {string[]} hints
39 | * @property {[{filePath: string; fileSeed: string} | string]} seed
40 | * @property {boolean?} isForce
41 | * @property {string?} beforeAll
42 | * @property {string?} afterAll
43 | * @property {string?} beforeEach
44 | * @property {string?} afterEach
45 | */
46 |
47 | export const pluginEvents = {
48 | /**
49 | * @param {Project} project
50 | * @param {TestsState[]} testsState
51 | */
52 | onTestsStart: async (project, testsState) => {},
53 |
54 | /**
55 | * @param {Project} project
56 | * @param {TestsState[]} testsState
57 | */
58 | onTestsEnd: async (project, testsState) => {},
59 |
60 | /**
61 | * @param {Project} project
62 | */
63 | onProjectStart: async project => {},
64 |
65 | /**
66 | * @param {Project} project
67 | */
68 | onProjectFinished: async project => {},
69 |
70 | /**
71 | * @param {Project} project
72 | */
73 | onLessonPassed: async project => {},
74 |
75 | /**
76 | * @param {Project} project
77 | */
78 | onLessonFailed: async project => {},
79 |
80 | /**
81 | * @param {Project} project
82 | */
83 | onLessonLoad: async project => {},
84 |
85 | /**
86 | * @param {string} projectDashedName
87 | * @returns {Promise<{title: string; description: string; numberOfLessons: number; tags: string[]}>}
88 | */
89 | getProjectMeta: async projectDashedName => {
90 | const { locale } = await getState();
91 | const projectFilePath = join(
92 | ROOT,
93 | freeCodeCampConfig.curriculum.locales[locale],
94 | projectDashedName + '.md'
95 | );
96 | const projectFile = await readFile(projectFilePath, 'utf8');
97 | const coffeeDown = new CoffeeDown(projectFile);
98 | const projectMeta = coffeeDown.getProjectMeta();
99 | // Remove `` tags if present
100 | const title = parseMarkdown(projectMeta.title)
101 | .replace(/
|<\/p>/g, '')
102 | .trim();
103 | const description = parseMarkdown(projectMeta.description).trim();
104 | const tags = projectMeta.tags;
105 | const numberOfLessons = projectMeta.numberOfLessons;
106 | return { title, description, numberOfLessons, tags };
107 | },
108 |
109 | /**
110 | * @param {string} projectDashedName
111 | * @param {number} lessonNumber
112 | * @returns {Promise} lesson
113 | */
114 | getLesson: async (projectDashedName, lessonNumber) => {
115 | const { locale } = await getState();
116 | const projectFilePath = join(
117 | ROOT,
118 | freeCodeCampConfig.curriculum.locales[locale],
119 | projectDashedName + '.md'
120 | );
121 | const projectFile = await readFile(projectFilePath, 'utf8');
122 | const coffeeDown = new CoffeeDown(projectFile);
123 | const lesson = coffeeDown.getLesson(lessonNumber);
124 | let seed = lesson.seed;
125 | if (!seed.length) {
126 | // Check for external seed file
127 | const seedFilePath = projectFilePath.replace(/.md$/, '-seed.md');
128 | try {
129 | const seedContent = await readFile(seedFilePath, 'utf-8');
130 | const coffeeDown = new CoffeeDown(seedContent);
131 | seed = coffeeDown.getLesson(lessonNumber).seed;
132 | } catch (e) {
133 | if (e?.code !== 'ENOENT') {
134 | logover.debug(e);
135 | throw new Error(
136 | `Error reading external seed for lesson ${lessonNumber}`
137 | );
138 | }
139 | }
140 | }
141 | const { afterAll, afterEach, beforeAll, beforeEach, isForce, meta } =
142 | lesson;
143 | const description = parseMarkdown(lesson.description).trim();
144 | const tests = lesson.tests.map(([testText, test]) => [
145 | parseMarkdown(testText).trim(),
146 | test
147 | ]);
148 | const hints = lesson.hints.map(h => parseMarkdown(h).trim());
149 | return {
150 | meta,
151 | description,
152 | tests,
153 | hints,
154 | seed,
155 | beforeAll,
156 | afterAll,
157 | beforeEach,
158 | afterEach,
159 | isForce
160 | };
161 | }
162 | };
163 |
--------------------------------------------------------------------------------
/.freeCodeCamp/client/styles.css:
--------------------------------------------------------------------------------
1 | :root {
2 | --dark-1: #0a0a23;
3 | --dark-2: #1b1b32;
4 | --dark-3: #2a2a40;
5 | --dark-4: #3b3b4f;
6 | --mid: #858591;
7 | --light-1: #ffffff;
8 | --light-2: #f5f6f7;
9 | --light-3: #dfdfe2;
10 | --light-4: #d0d0d5;
11 | --light-purple: #dbb8ff;
12 | --light-yellow: #f1be32;
13 | --light-blue: #99c9ff;
14 | --light-green: #acd157;
15 | --dark-purple: #5a01a7;
16 | --dark-yellow: #ffac33;
17 | --dark-something: #4d3800;
18 | --dark-blue: #002ead;
19 | --dark-green: #00471b;
20 | }
21 |
22 | @font-face {
23 | font-family: 'Lato';
24 | src: url('./assets/Lato-Regular.woff') format('woff');
25 | font-weight: normal;
26 | font-style: normal;
27 | font-display: fallback;
28 | }
29 |
30 | * {
31 | color: var(--light-2);
32 | line-height: 2.2ch;
33 | }
34 | body {
35 | background-color: var(--dark-2);
36 | margin: 0;
37 | text-align: center;
38 | font-family: 'Lato', sans-serif;
39 | font-weight: 400;
40 | }
41 |
42 | header {
43 | width: 100%;
44 | height: 38px;
45 | background-color: var(--dark-1);
46 | display: flex;
47 | justify-content: center;
48 | }
49 |
50 | .header-btn {
51 | all: unset;
52 | }
53 | button {
54 | font-family: inherit;
55 | }
56 | .header-btn:hover {
57 | cursor: pointer;
58 | }
59 |
60 | #logo {
61 | width: auto;
62 | height: 38px;
63 | max-height: 100%;
64 | background-color: var(--dark-1);
65 | padding: 0.4rem;
66 | display: block;
67 | margin: 0 auto;
68 | padding-left: 20px;
69 | padding-right: 20px;
70 | }
71 |
72 | p {
73 | font-size: 16px;
74 | }
75 |
76 | .loader {
77 | --b: 10px;
78 | /* border thickness */
79 | --n: 10;
80 | /* number of dashes*/
81 | --g: 10deg;
82 | /* gap between dashes*/
83 | --c: red;
84 | /* the color */
85 |
86 | width: 100px;
87 | /* size */
88 | margin: 0 auto;
89 | aspect-ratio: 1;
90 | border-radius: 50%;
91 | padding: 1px;
92 | /* get rid of bad outlines */
93 | background: conic-gradient(#0000, var(--c)) content-box;
94 | -webkit-mask:
95 | /* we use +/-1deg between colors to avoid jagged edges */ repeating-conic-gradient(
96 | #0000 0deg,
97 | #000 1deg calc(360deg / var(--n) - var(--g) - 1deg),
98 | #0000 calc(360deg / var(--n) - var(--g)) calc(360deg / var(--n))
99 | ),
100 | radial-gradient(
101 | farthest-side,
102 | #0000 calc(98% - var(--b)),
103 | #000 calc(100% - var(--b))
104 | );
105 | mask: repeating-conic-gradient(
106 | #0000 0deg,
107 | #000 1deg calc(360deg / var(--n) - var(--g) - 1deg),
108 | #0000 calc(360deg / var(--n) - var(--g)) calc(360deg / var(--n))
109 | ),
110 | radial-gradient(
111 | farthest-side,
112 | #0000 calc(98% - var(--b)),
113 | #000 calc(100% - var(--b))
114 | );
115 | -webkit-mask-composite: destination-in;
116 | mask-composite: intersect;
117 | animation: load 1s infinite steps(var(--n));
118 | }
119 | .width-10 {
120 | width: 10px;
121 | }
122 | .width-20 {
123 | width: 20px;
124 | }
125 | .width-30 {
126 | width: 30px;
127 | }
128 | .width-40 {
129 | width: 40px;
130 | }
131 | .width-50 {
132 | width: 50px;
133 | }
134 | .width-60 {
135 | width: 60px;
136 | }
137 | .width-70 {
138 | width: 70px;
139 | }
140 | .width-80 {
141 | width: 80px;
142 | }
143 | .width-90 {
144 | width: 90px;
145 | }
146 | [class*='width-'] {
147 | margin: 0;
148 | display: inline-block;
149 | }
150 |
151 | @keyframes load {
152 | to {
153 | transform: rotate(1turn);
154 | }
155 | }
156 |
157 | .hidden {
158 | display: none;
159 | }
160 |
161 | code {
162 | background-color: var(--dark-3);
163 | color: var(--light-1);
164 | padding: 1px;
165 | margin-top: 0.1rem;
166 | margin-bottom: 0.1rem;
167 | }
168 |
169 | #description pre {
170 | overflow-x: auto;
171 | }
172 |
173 | .test {
174 | padding-bottom: 1rem;
175 | }
176 |
177 | .test > div > p {
178 | display: inline;
179 | }
180 |
181 | details {
182 | padding-bottom: 1rem;
183 | }
184 |
185 | .e44o5 {
186 | text-align: left;
187 | }
188 |
189 | .e44o5 ul,
190 | .e44o5 ol {
191 | display: inline-block;
192 | }
193 |
194 | .e44o5 li {
195 | padding: 8px;
196 | }
197 |
198 | .block-info {
199 | display: flex;
200 | }
201 | .block-info > span {
202 | margin: auto 1em auto auto;
203 | }
204 |
205 | .sr-only {
206 | position: absolute;
207 | width: 1px;
208 | height: 1px;
209 | margin: -1px;
210 | padding: 0;
211 | overflow: hidden;
212 | clip: rect(0, 0, 0, 0);
213 | border: 0;
214 | }
215 |
216 | .block-checkmark {
217 | margin-left: 7px;
218 | position: relative;
219 | top: 1px;
220 | }
221 |
222 | #toggle-lang-button {
223 | margin: 0 0.5rem;
224 | position: absolute;
225 | right: 0;
226 | top: 0.28rem;
227 | }
228 |
229 | #toggle-lang-button:hover {
230 | cursor: pointer;
231 | }
232 |
233 | #nav-lang-list {
234 | position: absolute;
235 | right: 0;
236 | background-color: var(--dark-1);
237 | border-radius: 0.3rem;
238 | padding: 0.3rem;
239 | margin-top: 2.4rem;
240 | z-index: 1;
241 | }
242 | #nav-lang-list > li {
243 | padding: 0.28rem;
244 | list-style: none;
245 | }
246 |
247 | #nav-lang-list > li > button {
248 | width: 100%;
249 | color: var(--dark-3);
250 | }
251 |
252 | #nav-lang-list > li > button:hover {
253 | color: var(--light-2);
254 | background-color: var(--dark-3);
255 | cursor: pointer;
256 | }
257 |
--------------------------------------------------------------------------------
/docs/theme/css/general.css:
--------------------------------------------------------------------------------
1 | /* Base styles and content styles */
2 |
3 | @import 'variables.css';
4 |
5 | :root {
6 | /* Browser default font-size is 16px, this way 1 rem = 10px */
7 | font-size: 62.5%;
8 | }
9 |
10 | html {
11 | font-family: 'Lato', sans-serif;
12 | color: var(--fg);
13 | background-color: var(--bg);
14 | text-size-adjust: none;
15 | -webkit-text-size-adjust: none;
16 | }
17 |
18 | body {
19 | margin: 0;
20 | font-size: 1.6rem;
21 | overflow-x: hidden;
22 | }
23 |
24 | code {
25 | font-family: var(--mono-font) !important;
26 | font-size: var(--code-font-size);
27 | }
28 |
29 | /* make long words/inline code not x overflow */
30 | main {
31 | overflow-wrap: break-word;
32 | }
33 |
34 | /* make wide tables scroll if they overflow */
35 | .table-wrapper {
36 | overflow-x: auto;
37 | }
38 |
39 | /* Don't change font size in headers. */
40 | h1 code,
41 | h2 code,
42 | h3 code,
43 | h4 code,
44 | h5 code,
45 | h6 code {
46 | font-size: unset;
47 | }
48 |
49 | .left {
50 | float: left;
51 | }
52 | .right {
53 | float: right;
54 | }
55 | .boring {
56 | opacity: 0.6;
57 | }
58 | .hide-boring .boring {
59 | display: none;
60 | }
61 | .hidden {
62 | display: none !important;
63 | }
64 |
65 | h2,
66 | h3 {
67 | margin-top: 2.5em;
68 | }
69 | h4,
70 | h5 {
71 | margin-top: 2em;
72 | }
73 |
74 | .header + .header h3,
75 | .header + .header h4,
76 | .header + .header h5 {
77 | margin-top: 1em;
78 | }
79 |
80 | h1:target::before,
81 | h2:target::before,
82 | h3:target::before,
83 | h4:target::before,
84 | h5:target::before,
85 | h6:target::before {
86 | display: inline-block;
87 | content: '»';
88 | margin-left: -30px;
89 | width: 30px;
90 | }
91 |
92 | /* This is broken on Safari as of version 14, but is fixed
93 | in Safari Technology Preview 117 which I think will be Safari 14.2.
94 | https://bugs.webkit.org/show_bug.cgi?id=218076
95 | */
96 | :target {
97 | scroll-margin-top: calc(var(--menu-bar-height) + 0.5em);
98 | }
99 |
100 | .page {
101 | outline: 0;
102 | padding: 0 var(--page-padding);
103 | margin-top: calc(0px - var(--menu-bar-height));
104 | /* Compensate for the #menu-bar-hover-placeholder */
105 | }
106 | .page-wrapper {
107 | box-sizing: border-box;
108 | }
109 | .js:not(.sidebar-resizing) .page-wrapper {
110 | transition: margin-left 0.3s ease, transform 0.3s ease;
111 | /* Animation: slide away */
112 | }
113 |
114 | .content {
115 | overflow-y: auto;
116 | padding: 0 5px 50px 5px;
117 | }
118 | .content main {
119 | margin-left: auto;
120 | margin-right: auto;
121 | max-width: var(--content-max-width);
122 | }
123 | .content p {
124 | line-height: 1.45em;
125 | }
126 | .content ol {
127 | line-height: 1.45em;
128 | }
129 | .content ul {
130 | line-height: 1.45em;
131 | }
132 | .content a {
133 | text-decoration: none;
134 | }
135 | .content a:hover {
136 | text-decoration: underline;
137 | }
138 | .content img,
139 | .content video {
140 | max-width: 100%;
141 | }
142 | .content .header:link,
143 | .content .header:visited {
144 | color: var(--fg);
145 | }
146 | .content .header:link,
147 | .content .header:visited:hover {
148 | text-decoration: none;
149 | }
150 |
151 | table {
152 | margin: 0 auto;
153 | border-collapse: collapse;
154 | }
155 | table td {
156 | padding: 3px 20px;
157 | border: 1px var(--table-border-color) solid;
158 | }
159 | table thead {
160 | background: var(--table-header-bg);
161 | }
162 | table thead td {
163 | font-weight: 700;
164 | border: none;
165 | }
166 | table thead th {
167 | padding: 3px 20px;
168 | }
169 | table thead tr {
170 | border: 1px var(--table-header-bg) solid;
171 | }
172 | /* Alternate background colors for rows */
173 | table tbody tr:nth-child(2n) {
174 | background: var(--table-alternate-bg);
175 | }
176 |
177 | blockquote {
178 | margin: 20px 0;
179 | padding: 0 20px;
180 | color: var(--fg);
181 | background-color: var(--quote-bg);
182 | border-top: 0.1em solid var(--quote-border);
183 | border-bottom: 0.1em solid var(--quote-border);
184 | }
185 |
186 | kbd {
187 | background-color: var(--table-border-color);
188 | border-radius: 4px;
189 | border: solid 1px var(--theme-popup-border);
190 | box-shadow: inset 0 -1px 0 var(--theme-hover);
191 | display: inline-block;
192 | font-size: var(--code-font-size);
193 | font-family: var(--mono-font);
194 | line-height: 10px;
195 | padding: 4px 5px;
196 | vertical-align: middle;
197 | }
198 |
199 | :not(.footnote-definition) + .footnote-definition,
200 | .footnote-definition + :not(.footnote-definition) {
201 | margin-top: 2em;
202 | }
203 | .footnote-definition {
204 | font-size: 0.9em;
205 | margin: 0.5em 0;
206 | }
207 | .footnote-definition p {
208 | display: inline;
209 | }
210 |
211 | .tooltiptext {
212 | position: absolute;
213 | visibility: hidden;
214 | color: #fff;
215 | background-color: #333;
216 | transform: translateX(-50%);
217 | /* Center by moving tooltip 50% of its width left */
218 | left: -8px;
219 | /* Half of the width of the icon */
220 | top: -35px;
221 | font-size: 0.8em;
222 | text-align: center;
223 | border-radius: 6px;
224 | padding: 5px 8px;
225 | margin: 5px;
226 | z-index: 1000;
227 | }
228 | .tooltipped .tooltiptext {
229 | visibility: visible;
230 | }
231 |
232 | .chapter li.part-title {
233 | color: var(--sidebar-fg);
234 | margin: 5px 0px;
235 | font-weight: bold;
236 | }
237 |
238 | .result-no-output {
239 | font-style: italic;
240 | }
241 |
242 | dfn[title] {
243 | text-decoration: underline dotted;
244 | }
--------------------------------------------------------------------------------
/docs/src/configuration.md:
--------------------------------------------------------------------------------
1 | # Configuration
2 |
3 | ## `freecodecamp.conf.json`
4 |
5 | ### Required Configuration
6 |
7 | ```json
8 | {
9 | "version": "0.0.1",
10 | "config": {
11 | "projects.json": "",
12 | "state.json": ""
13 | },
14 | "curriculum": {
15 | "locales": {
16 | "": ""
17 | }
18 | }
19 | }
20 | ```
21 |
22 | ````admonish example collapsible=true title="Minimum Usable Example"
23 | ```json
24 | {
25 | "version": "0.0.1",
26 | "config": {
27 | "projects.json": "./config/projects.json",
28 | "state.json": "./config/state.json"
29 | },
30 | "curriculum": {
31 | "locales": {
32 | "english": "./curriculum/locales/english"
33 | }
34 | }
35 | }
36 | ```
37 | ````
38 |
39 | ### Optional Configuration (Features)
40 |
41 | #### `port`
42 |
43 | By default, the server and client communicate over port `8080`. To change this, add a `port` key to the configuration file:
44 |
45 | ````admonish example
46 | ```json
47 | {
48 | "port": 8080
49 | }
50 | ```
51 | ````
52 |
53 | #### `client`
54 |
55 | - `assets.header`: path relative to the root of the course - `string`
56 | - `assets.favicon`: path relative to the root of the course - `string`
57 | - `landing..description`: description of the course shown on the landing page - `string`
58 | - `landing..title`: title of the course shown on the landing page - `string`
59 | - `landing..faq-link`: link to the FAQ page - `string`
60 | - `landing..faq-text`: text to display for the FAQ link - `string`
61 | - `static`: static resources to serve - `string | string[] | Record | Record[]`
62 |
63 | ````admonish example
64 | ```json
65 | {
66 | "client": {
67 | "assets": {
68 | "header": "./client/assets/header.png",
69 | "favicon": "./client/assets/favicon.ico"
70 | },
71 | "static": ["./curriculum/", { "/images": "./curriculum/images" }]
72 | }
73 | }
74 | ```
75 | ````
76 |
77 | #### `config`
78 |
79 | - `projects.json`: path relative to the root of the course - `string`
80 | - `state.json`: path relative to the root of the course - `string`
81 |
82 | ````admonish example
83 | ```json
84 | {
85 | "config": {
86 | "projects.json": "./config/projects.json",
87 | "state.json": "./config/state.json"
88 | }
89 | }
90 | ```
91 | ````
92 |
93 | #### `curriculum`
94 |
95 | - `locales`: an object of locale names and their corresponding paths relative to the root of the course - `Record`
96 | - `assertions`: an onject of locale names and their corresponding paths to a JSON file containing custom assertions - `string`
97 |
98 | ````admonish example
99 | ```json
100 | {
101 | "curriculum": {
102 | "locales": {
103 | "english": "./curriculum/locales/english"
104 | },
105 | "assertions": {
106 | "afrikaans": "./curriculum/assertions/afrikaans.json"
107 | }
108 | }
109 | }
110 | ```
111 | ````
112 |
113 | ```admonish attention
114 | Currently, `english` is a required locale, and is used as the default.
115 | ```
116 |
117 | #### `hotReload`
118 |
119 | - `ignore`: a list of paths to ignore when hot reloading - `string[]`
120 |
121 | ````admonish example
122 | ```json
123 | {
124 | "hotReload": {
125 | "ignore": [".logs/.temp.log", "config/", "/node_modules/", ".git"]
126 | }
127 | }
128 | ```
129 | ````
130 |
131 | #### `tooling`
132 |
133 | - `helpers`: path relative to the root of the course - `string`
134 | - `plugins`: path relative to the root of the course - `string`
135 |
136 | ````admonish example
137 | ```json
138 | {
139 | "tooling": {
140 | "helpers": "./tooling/helpers.js",
141 | "plugins": "./tooling/plugins.js"
142 | }
143 | }
144 | ```
145 | ````
146 |
147 | ## `projects.json`
148 |
149 | ### Definitions
150 |
151 | - `id`: A unique, incremental integer - `number`
152 | - `dashedName`: The name of the project corresponding to the `curriculum/locales/.md` file - `string`
153 | - `isIntegrated`: Whether or not to treat the project as a single-lesson project - `boolean` (default: `false`)
154 | - `isPublic`: Whether or not to enable the project for public viewing. **Note:** the project will still be visible on the landing page, but will be disabled - `boolean` (default: `false`)
155 | - `currentLesson`: The current lesson of the project - `number` (default: `1`)
156 | - `runTestsOnWatch`: Whether or not to run tests on file change - `boolean` (default: `false`)
157 | - `isResetEnabled`: Whether or not to enable the reset button - `boolean` (default: `false`)
158 | - `numberOfLessons`: The number of lessons in the project - `number`[^1]
159 | - `seedEveryLesson`: Whether or not to run the seed on lesson load - `boolean` (default: `false`)
160 | - `blockingTests`: Run tests synchronously - `boolean` (default: `false`)
161 | - `breakOnFailure`: Stop running tests on the first failure - `boolean` (default: `false`)
162 |
163 | [^1]: This is automagically calculated when the app is launched.
164 |
165 | ### Required Configuration
166 |
167 | ```json
168 | [
169 | {
170 | "id": 0, // Unique ID
171 | "dashedName": ""
172 | }
173 | ]
174 | ```
175 |
176 | ### Optional Configuration
177 |
178 | ````admonish example
179 | ```json
180 | [
181 | {
182 | "id": 0,
183 | "dashedName": "learn-x-by-building-y",
184 | "isIntegrated": false,
185 | "isPublic": false,
186 | "currentLesson": 1,
187 | "runTestsOnWatch": false,
188 | "isResetEnabled": false,
189 | "numberOfLessons": 10,
190 | "seedEveryLesson": false,
191 | "blockingTests": false,
192 | "breakOnFailure": false
193 | }
194 | ]
195 | ```
196 | ````
197 |
198 | ## `.gitignore`
199 |
200 | ### Retaining Files When a Step is Reset
201 |
202 | ```admonish warning
203 | Resetting a step removes all untracked files from the project directory. To prevent this for specific files, add them to a boilerplate `.gitignore` file, or the one in root.
204 | ```
205 |
--------------------------------------------------------------------------------
/.freeCodeCamp/tooling/git/gitterizer.js:
--------------------------------------------------------------------------------
1 | // This file handles the fetching/parsing of the Git status of the project
2 | import { promisify } from 'util';
3 | import { exec } from 'child_process';
4 | import { getState, setState } from '../env.js';
5 | import { logover } from '../logger.js';
6 | const execute = promisify(exec);
7 |
8 | /**
9 | * Runs the following commands:
10 | *
11 | * ```bash
12 | * git add .
13 | * git commit --allow-empty -m "()"
14 | * ```
15 | *
16 | * @param {number} lessonNumber
17 | * @returns {Promise}
18 | */
19 | export async function commit(lessonNumber) {
20 | try {
21 | const { stdout, stderr } = await execute(
22 | `git add . && git commit --allow-empty -m "(${lessonNumber})"`
23 | );
24 | if (stderr) {
25 | logover.error('Failed to commit lesson: ', lessonNumber);
26 | throw new Error(stderr);
27 | }
28 | } catch (e) {
29 | return Promise.reject(e);
30 | }
31 | return Promise.resolve();
32 | }
33 |
34 | /**
35 | * Initialises a new branch for the `CURRENT_PROJECT`
36 | * @returns {Promise}
37 | */
38 | export async function initCurrentProjectBranch() {
39 | const { currentProject } = await getState();
40 | try {
41 | const { stdout, stderr } = await execute(
42 | `git checkout -b ${currentProject}`
43 | );
44 | // SILlY GIT PUTS A BRANCH SWITCH INTO STDERR!!!
45 | // if (stderr) {
46 | // throw new Error(stderr);
47 | // }
48 | } catch (e) {
49 | return Promise.reject(e);
50 | }
51 | return Promise.resolve();
52 | }
53 |
54 | /**
55 | * Returns the commit hash of the branch `origin/`
56 | * @param {number} number
57 | * @returns {Promise}
58 | */
59 | export async function getCommitHashByNumber(number) {
60 | const { lastKnownLessonWithHash, currentProject } = await getState();
61 | try {
62 | const { stdout, stderr } = await execute(
63 | `git log origin/${currentProject} --oneline --grep="(${number})" --`
64 | );
65 | if (stderr) {
66 | throw new Error(stderr);
67 | }
68 | const hash = stdout.match(/\w+/)?.[0];
69 | // This keeps track of the latest known commit in case there are no commits from one lesson to the next
70 | if (!hash) {
71 | return getCommitHashByNumber(lastKnownLessonWithHash);
72 | }
73 | await setState({ lastKnownLessonWithHash: number });
74 | return hash;
75 | } catch (e) {
76 | throw new Error(e);
77 | }
78 | }
79 |
80 | /**
81 | * Aborts and in-progress `cherry-pick`
82 | * @returns {Promise}
83 | */
84 | async function ensureNoUnfinishedGit() {
85 | try {
86 | const { stdout, stderr } = await execute(`git cherry-pick --abort`);
87 | // Throwing in a `try` probably does not make sense
88 | if (stderr) {
89 | throw new Error(stderr);
90 | }
91 | } catch (e) {
92 | return Promise.reject(e);
93 | }
94 | return Promise.resolve();
95 | }
96 |
97 | /**
98 | * Git cleans the current branch, then `cherry-pick`s the commit hash found by `lessonNumber`
99 | * @param {number} lessonNumber
100 | * @returns {Promise}
101 | */
102 | export async function setFileSystemToLessonNumber(lessonNumber) {
103 | await ensureNoUnfinishedGit();
104 | const endHash = await getCommitHashByNumber(lessonNumber);
105 | const firstHash = await getCommitHashByNumber(1);
106 | try {
107 | // TODO: Continue on this error? Or, bail?
108 | if (!endHash || !firstHash) {
109 | throw new Error('Could not find commit hash');
110 | }
111 | // VOLUME BINDING?
112 | //
113 | // TODO: Probably do not want to always completely clean for each lesson
114 | if (firstHash === endHash) {
115 | await execute(`git clean -f -q -- . && git cherry-pick ${endHash}`);
116 | } else {
117 | // TODO: Why not git checkout ${endHash}
118 | const { stdout, stderr } = await execute(
119 | `git clean -f -q -- . && git cherry-pick ${firstHash}^..${endHash}`
120 | );
121 | if (stderr) {
122 | throw new Error(stderr);
123 | }
124 | }
125 | } catch (e) {
126 | return Promise.reject(e);
127 | }
128 | return Promise.resolve();
129 | }
130 |
131 | /**
132 | * Pushes the `` branch to `origin`
133 | * @returns {Promise}
134 | */
135 | export async function pushProject() {
136 | const { currentProject } = await getState();
137 | try {
138 | const { stdout, stderr } = await execute(
139 | `git push origin ${currentProject} --force`
140 | );
141 | // if (stderr) {
142 | // throw new Error(stderr);
143 | // }
144 | } catch (e) {
145 | logover.error('Failed to push project ', currentProject);
146 | return Promise.reject(e);
147 | }
148 | return Promise.resolve();
149 | }
150 |
151 | /**
152 | * Checks out the `main` branch
153 | *
154 | * **IMPORTANT**: This function restores any/all git changes that are uncommitted.
155 | * @returns {Promise}
156 | */
157 | export async function checkoutMain() {
158 | try {
159 | await execute('git restore .');
160 | const { stdout, stderr } = await execute(`git checkout main`);
161 | // if (stderr) {
162 | // throw new Error(stderr);
163 | // }
164 | } catch (e) {
165 | return Promise.reject(e);
166 | }
167 | return Promise.resolve();
168 | }
169 |
170 | /**
171 | * If the given branch is found to exist, deletes the branch
172 | * @param {string} branch
173 | * @returns {Promise}
174 | */
175 | export async function deleteBranch(branch) {
176 | const isBranchExists = await branchExists(branch);
177 | if (!isBranchExists) {
178 | return Promise.resolve();
179 | }
180 | logover.warn('Deleting branch ', branch);
181 | try {
182 | await checkoutMain();
183 | const { stdout, stderr } = await execute(`git branch -D ${branch}`);
184 | logover.info(stdout);
185 | // if (stderr) {
186 | // throw new Error(stderr);
187 | // }
188 | } catch (e) {
189 | logover.error('Failed to delete branch: ', branch);
190 | return Promise.reject(e);
191 | }
192 | return Promise.resolve();
193 | }
194 |
195 | /**
196 | * Checks if the given branch exists
197 | * @param {string} branch
198 | * @returns {Promise}
199 | */
200 | export async function branchExists(branch) {
201 | try {
202 | const { stdout, stderr } = await execute(`git branch --list ${branch}`);
203 | return Promise.resolve(stdout.includes(branch));
204 | } catch (e) {
205 | return Promise.reject(e);
206 | }
207 | }
208 |
--------------------------------------------------------------------------------