Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 12 additions & 2 deletions apps/mobile/components/CodeBlock.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
import type { ReactNode } from 'react';
import { ScrollView, StyleSheet, Text } from 'react-native';
import { ScrollView, StyleSheet, Text, View } from 'react-native';

import { colors, fonts } from '@/constants/theme';

interface CodeBlockProps {
code: string;
// 選項按鈕(Pressable)內部不能放 ScrollView,iOS 上 touchable 會搶走捲動手勢,
// 這種情境改用 scroll={false} 讓長行直接換行,不強制水平捲動
scroll?: boolean;
}

// 對照 apps/web CodeBlock.tsx 的輕量語法上色:註解、字串、關鍵字、數字(同一份 regex)
Expand Down Expand Up @@ -39,8 +42,15 @@ const highlight = (code: string): ReactNode[] => {
return last < code.length ? [...parts, code.slice(last)] : parts;
};

export default function CodeBlock({ code }: CodeBlockProps) {
export default function CodeBlock({ code, scroll = true }: CodeBlockProps) {
if (!code) return null;
if (!scroll) {
return (
<View style={styles.wrap}>
<Text style={styles.code}>{highlight(code)}</Text>
</View>
);
}
return (
<ScrollView horizontal showsHorizontalScrollIndicator={false} style={styles.wrap}>
<Text style={styles.code}>{highlight(code)}</Text>
Expand Down
1 change: 1 addition & 0 deletions apps/mobile/components/QuestionCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ export default function QuestionCard({
answered && !isAnswer && !isPicked && styles.optionDimmed,
]}
>
{opt.code && <CodeBlock code={opt.code} scroll={false} />}
<Text
style={[
styles.optionText,
Expand Down
1 change: 1 addition & 0 deletions apps/mobile/screens/QuestionReview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ export default function QuestionReview({ question, saved, onToggleSave, meta }:
const isAnswer = opt.id === question.answer;
return (
<View key={opt.id} style={[styles.optionBtn, isAnswer ? styles.optionCorrect : styles.optionDimmed]}>
{opt.code && <CodeBlock code={opt.code} scroll={false} />}
<Text style={[styles.optionText, isAnswer && styles.optionTextCorrect]}>{opt.text}</Text>
</View>
);
Expand Down
20 changes: 16 additions & 4 deletions apps/web/src/components/QuestionCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,15 +55,27 @@ const QuestionCard = ({
else if (opt.id === selected) cls += ' is-wrong'
else cls += ' is-dimmed'
}
const selectOption = () => {
if (!answered) onSelect(opt.id)
}
return (
<button
<div
key={opt.id}
role="button"
tabIndex={0}
aria-disabled={answered}
className={cls}
disabled={answered}
onClick={() => onSelect(opt.id)}
onClick={selectOption}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
if (e.key === ' ') e.preventDefault()
selectOption()
}
}}
>
{opt.code && <CodeBlock code={opt.code} />}
{opt.text}
</button>
</div>
)
})}
</div>
Expand Down
3 changes: 2 additions & 1 deletion apps/web/src/components/QuestionReview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,14 +38,15 @@ const QuestionReview = ({ question, saved, onToggleSave, meta }: QuestionReviewP
)}

<p className="question-prompt">{question.prompt}</p>
<CodeBlock code={question.code} />
{question.code && <CodeBlock code={question.code} />}

<div className="options">
{question.options.map((opt) => (
<div
key={opt.id}
className={`option-btn is-static ${opt.id === question.answer ? 'is-correct' : 'is-dimmed'}`}
>
{opt.code && <CodeBlock code={opt.code} />}
{opt.text}
</div>
))}
Expand Down
12 changes: 11 additions & 1 deletion apps/web/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -937,9 +937,19 @@ button {
transition: border-color 0.15s;
font-family: inherit;
word-break: break-all;
cursor: pointer;
}

.option-btn .code-block {
margin: 0 0 8px;
text-align: left;
}

.option-btn[aria-disabled='true'] {
cursor: default;
}

.option-btn:not(:disabled):hover {
.option-btn:not([aria-disabled='true']):hover {
border-color: var(--primary);
}

Expand Down
12 changes: 6 additions & 6 deletions docs/question-format.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,16 +24,16 @@
| 欄位 | 必填 | 說明 |
|:--|:--|:--|
| `id` | ✅ | 題目唯一代號,格式 `{關卡id}-q{序號}` |
| `type` | ✅ | 題型:`predict-output`(預測輸出)、`find-bug`(抓 bug)、`same-or-not`(改壞了嗎)、`fill-in`(微量手寫填空) |
| `type` | ✅ | 題型:`predict-output`(預測輸出)、`find-bug`(抓 bug)、`same-or-not`(改壞了嗎)、`fill-in`(微量手寫填空)、`concept`(純概念題,無需執行驗證) |
| `difficulty` | ✅ | 1–3,同一關內由易到難排列 |
| `topic` | ✅ | 知識點名稱(顯示用) |
| `docs` | ✅ | 對應的 MDN / react.dev 文件連結(答題後顯示「延伸閱讀」) |
| `docs` | ✅ | 對應的 MDN / react.dev 文件連結(答題後顯示「延伸閱讀」),`concept` 題若無明確對應文件可留空字串 |
| `story` | ✅ | 劇情包裝文字,MVP 一律空字串 `""`(二期職場敘事預留欄位) |
| `prompt` | ✅ | 題目問句 |
| `code` | ✅ | 展示給學員看的程式碼;`fill-in` 題用 `____` 標記空格 |
| `options` | ✅ | 選項陣列 `[{ "id": "a", "text": "..." }]`,2–4 個 |
| `prompt` | ✅ | **只放題目問句**,不放程式碼;即使要比較「版本一/版本二」兩段程式碼,也一律放進 `code` 欄位,`prompt` 只問「這兩者的差異說明了什麼」 |
| `code` | ✅ | 展示給學員看的程式碼;`fill-in` 題用 `____` 標記空格;比較多個版本時用 `// 版本一` / `// 版本二` 註解分隔(同一 `code` 欄位可多行),沒有程式碼可展示的 `concept` 題留空字串 `""` |
| `options` | ✅ | 選項陣列 `[{ "id": "a", "text": "...", "code": "..." }]`,2–4 個;`code` 為選填,只有當這個選項本身是一段完整程式碼(例如「哪一種重構寫法正確」)時才用,`text` 留給簡短說明,不要把整段函式定義塞進 `text` 字串 |
| `answer` | ✅ | 正確選項的 id |
| `explanation` | ✅ | 白話解釋——先講「發生了什麼」,再講規則;語氣鼓勵、不說教 |
| `explanation` | ✅ | 白話解釋——先講「發生了什麼」,再講規則;語氣鼓勵、不說教;用文字描述程式碼行為,不要在 `explanation` 裡內嵌完整程式碼(沒有對應的 code 欄位可以承載,會被當純文字擠成一行) |
| `verify` | ✅ | 自動驗證設定,見下 |

## verify 欄位(自動驗證)
Expand Down
8 changes: 4 additions & 4 deletions packages/core/src/data/questions/fp-1-welcome.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,19 +6,19 @@
"id": "fp-1-q1",
"type": "concept",
"difficulty": 1,
"topic": "本書對 FP 的定義",
"topic": "FP 如何看待副作用",
"docs": "",
"story": "",
"prompt": "同樣是「新用戶註冊後寄送歡迎信」,有兩種寫法:\n版本一:function onSignup(user) { sendWelcomeEmail(user); logAnalytics(user); saveToDb(user); } // 判斷內容、寄信、記錄全部寫在一起\n版本二:function buildWelcomeEmail(user) { return { to: user.email, subject: '歡迎加入' }; } // 只負責算出信件內容,不寄信\n function onSignup(user) { var email = buildWelcomeEmail(user); sendEmail(email); saveToDb(user); }\n根據這兩種寫法的差異,哪一個最符合本書對「函數式程式設計」的核心主張?",
"code": "",
"prompt": "同樣是「新用戶註冊後寄送歡迎信」,有兩種寫法,根據這兩種寫法的差異,哪一個最符合函數式程式設計看待「副作用」的方式?",
"code": "// 版本一\nfunction onSignup(user) {\n sendWelcomeEmail(user);\n logAnalytics(user);\n saveToDb(user);\n} // 判斷內容、寄信、記錄全部寫在一起\n\n// 版本二\nfunction buildWelcomeEmail(user) {\n return { to: user.email, subject: '歡迎加入' };\n} // 只負責算出信件內容,不寄信\nfunction onSignup(user) {\n var email = buildWelcomeEmail(user);\n sendEmail(email);\n saveToDb(user);\n}",
"options": [
{ "id": "a", "text": "版本二把「決定信件內容」這種可以獨立驗證、不需要真的寄信就能檢查對不對的部分,跟「寄信」這種一定會發生副作用的動作分開;FP 的重點不是把 sendEmail 也消滅掉,而是把可以獨立出來的邏輯跟不可避免的副作用分辨清楚,讓副作用的範圍縮到最小" },
{ "id": "b", "text": "版本一比較符合 FP,因為所有邏輯集中在同一個函式裡,程式碼比較短" },
{ "id": "c", "text": "兩個版本沒有本質差異,FP 只在乎程式碼的行數多寡" },
{ "id": "d", "text": "FP 的重點是把版本二裡的 sendEmail 也硬拆成一個不會寄信的「純函式」,副作用完全不該出現在程式的任何地方" }
],
"answer": "a",
"explanation": "本書刻意不用「零副作用」當定義,因為真實世界的程式一定要跟外部世界互動(寄信、寫資料庫)。版本二示範的正是 FP 想做的事:把「決定內容」這種能拆出來獨立驗證的邏輯(buildWelcomeEmail),跟真正無法避免的副作用(sendEmail、saveToDb)分開,而不是妄想把副作用完全消滅。",
"explanation": "函數式程式設計刻意不用「零副作用」當標準,因為真實世界的程式一定要跟外部世界互動(寄信、寫資料庫)。版本二示範的正是 FP 想做的事:把「決定內容」這種能拆出來獨立驗證的邏輯(buildWelcomeEmail),跟真正無法避免的副作用(sendEmail、saveToDb)分開,而不是妄想把副作用完全消滅。",
"verify": { "manual": "概念題:以具體對照範例取代書中定義的直接背誦,無可執行程式碼。" }
},
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
{ "id": "d", "text": "這個差異只在瀏覽器環境成立,Node.js 環境裡 if 也可以被當成變數傳遞" }
],
"answer": "a",
"explanation": "「函式是 first-class」是 Part 2 一切技巧的地基:正因為函式可以被當資料傳來傳去,才能有高階函式、函式工廠這些抽象手法;+、if 這類語法結構做不到這件事。",
"explanation": "「函式是 first-class」是後續一切「把函式當資料操作」技巧的地基:正因為函式可以被當資料傳來傳去,才能有高階函式、函式工廠這些抽象手法;+、if 這類語法結構做不到這件事。",
"verify": { "manual": "概念題:以具體語法對比取代抽象定義敘述,無可執行程式碼。" }
},
{
Expand Down Expand Up @@ -50,13 +50,13 @@
"prompt": "setPriceByName、setQuantityByName、setShippingByName 這三個高度相似的函式,哪一種重構正確地合併成一個 setFieldByName?",
"code": "function setPriceByName(cart, name, price) { /* ... */ }\nfunction setQuantityByName(cart, name, quantity) { /* ... */ }\nfunction setShippingByName(cart, name, shipping) { /* ... */ }",
"options": [
{ "id": "a", "text": "function setFieldByName(cart, name, field, value) { var i = cart.findIndex(item => item.name === name); var copy = cart.slice(); copy[i] = { ...copy[i], [field]: value }; return copy; }——呼叫端改成 setFieldByName(cart, \"shirt\", \"price\", 13),一個函式取代三個重複的函式" },
{ "id": "a", "text": "呼叫端改成 setFieldByName(cart, \"shirt\", \"price\", 13),一個函式取代三個重複的函式", "code": "function setFieldByName(cart, name, field, value) {\n var i = cart.findIndex(item => item.name === name);\n var copy = cart.slice();\n copy[i] = { ...copy[i], [field]: value };\n return copy;\n}" },
{ "id": "b", "text": "把三個函式合併成 setAllFields(cart, name, price, quantity, shipping),強迫每次呼叫都要傳齊所有欄位" },
{ "id": "c", "text": "保留三個函式不變,只是讓它們共用同一個變數名稱" },
{ "id": "d", "text": "用一個全域變數紀錄目前要改哪個欄位,三個函式都改成讀這個全域變數" }
],
"answer": "a",
"explanation": "把「欄位名」從函式名稱搬進參數列,是本章最核心的重構:三個重複的函式合而為一,未來要加新欄位也不用再新增一個相似函式。",
"explanation": "把「欄位名」從函式名稱搬進參數列,是這類重複函式最核心的重構手法:三個重複的函式合而為一,未來要加新欄位也不用再新增一個相似函式。",
"verify": { "manual": "概念題:程式實作型改寫成選擇題,正解程式碼已於選項給出,無需執行驗證。" }
},
{
Expand All @@ -66,8 +66,8 @@
"topic": "高階函式的能力",
"docs": "",
"story": "",
"prompt": "myArray.map(function (x) { return x * 2; }) 這行程式裡,map 完全不知道要怎麼「乘以 2」,這個邏輯是呼叫端用參數傳進去的;但如果自己寫一個 function doubleAll(array) { return array.map(function (x) { return x * 2; }); },doubleAll 就只能做「乘以 2」這一件事。map 和 doubleAll 的差異說明了高階函式的什麼能力?",
"code": "",
"prompt": "map 完全不知道要怎麼「乘以 2」,這個邏輯是呼叫端用參數傳進去的;但如果自己寫一個 doubleAll,它就只能做「乘以 2」這一件事。map 和 doubleAll 的差異說明了高階函式的什麼能力?",
"code": "myArray.map(function (x) { return x * 2; });\n\n// 自己寫一個只能做這一件事的函式\nfunction doubleAll(array) {\n return array.map(function (x) { return x * 2; });\n}",
"options": [
{ "id": "a", "text": "map 是高階函式(接受函式當參數),它能把「要走訪陣列」這個固定的流程,跟「每個元素要怎麼被轉換」這個可變的邏輯分開;doubleAll 把兩者寫死在一起,只能做一件事——高階函式讓同一段控制流程可以套用在無限多種不同的具體邏輯上,這是只能操作資料的一般函式做不到的" },
{ "id": "b", "text": "map 跟 doubleAll 其實完全等價,只是寫法不同,沒有能力上的差異" },
Expand Down
4 changes: 2 additions & 2 deletions packages/core/src/data/questions/fp-13-chaining.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@
"topic": "鏈式呼叫的可讀性",
"docs": "",
"story": "",
"prompt": "比較兩種寫法,都是「找出下單次數超過 5 次的顧客 email,取前 3 名」:\n版本一:customers.filter(c => c.orders.length > 5).map(c => c.email).slice(0, 3)\n版本二:const frequentBuyers = customers.filter(c => c.orders.length > 5); const emails = frequentBuyers.map(c => c.email); const top3 = emails.slice(0, 3);\n步驟只有 3 步時兩者都好讀;如果串連的步驟變成 6、7 步呢?",
"code": "",
"prompt": "比較兩種寫法,都是「找出下單次數超過 5 次的顧客 email,取前 3 名」。步驟只有 3 步時兩者都好讀;如果串連的步驟變成 6、7 步呢?",
"code": "// 版本一\ncustomers.filter(c => c.orders.length > 5).map(c => c.email).slice(0, 3);\n\n// 版本二\nconst frequentBuyers = customers.filter(c => c.orders.length > 5);\nconst emails = frequentBuyers.map(c => c.email);\nconst top3 = emails.slice(0, 3);",
"options": [
{ "id": "a", "text": "步驟一多,版本一那種一路串到底的寫法會讓人很難一眼看出「這一步做完之後,資料變成了什麼形狀」;版本二把每個中間結果存進有意義名稱的變數,等於用變數名稱標註了每一步的產出,步驟越多,這種寫法的可讀性優勢越明顯" },
{ "id": "b", "text": "不管幾個步驟,版本一永遠比版本二好讀,因為程式碼行數比較少" },
Expand Down
10 changes: 5 additions & 5 deletions packages/core/src/data/questions/fp-14-nested-data.json
Original file line number Diff line number Diff line change
Expand Up @@ -97,16 +97,16 @@
"topic": "遞迴的三個安全守則",
"docs": "",
"story": "",
"prompt": "這段程式碼會無限遞迴、把呼叫堆疊塞爆:\nfunction badNestedUpdate(object, keys, modify) {\n var key1 = keys[0];\n var restOfKeys = keys.slice(1);\n return update(object, key1, function (value1) {\n return badNestedUpdate(value1, restOfKeys, modify);\n });\n}\n對照正確版本會多一行 if (keys.length === 0) return modify(object);,這段程式碼缺了遞迴的哪個安全守則?",
"code": "",
"prompt": "這段程式碼在 keys 用完之後不會正常停止,而是會拋出 TypeError(嘗試存取 undefined 的屬性)。對照正確版本會多一行 if (keys.length === 0) return modify(object);,這段程式碼缺了遞迴的哪個安全守則?",
"code": "function badNestedUpdate(object, keys, modify) {\n var key1 = keys[0];\n var restOfKeys = keys.slice(1);\n return update(object, key1, function (value1) {\n return badNestedUpdate(value1, restOfKeys, modify);\n });\n}",
"options": [
{ "id": "a", "text": "缺了「基本情況(base case)」——沒有檢查 keys 是不是已經空了就直接停止遞迴,所以就算 restOfKeys 每次都用 slice(1) 縮短,程式還是會不斷往下呼叫,永遠碰不到終止條件,最終讓呼叫堆疊爆掉" },
{ "id": "a", "text": "缺了「基本情況(base case)」——沒有檢查 keys 是不是已經空了就直接停止遞迴。keys 用 slice(1) 縮短到空陣列後就不會再變短,之後每次呼叫的 key1 都是 undefined,object 也跟著逐步變成 undefined,最終在 update 內部執行 object[key] 時因為 object 已經是 undefined 而拋出 TypeError" },
{ "id": "b", "text": "缺了「遞迴呼叫」,因為程式碼裡完全沒有呼叫 badNestedUpdate 自己" },
{ "id": "c", "text": "缺了「往基本情況前進」,因為 keys 的長度從頭到尾都沒有改變" },
{ "id": "d", "text": "這段程式碼完全沒有問題,不會發生無限遞迴" }
{ "id": "d", "text": "這段程式碼完全沒有問題,執行起來不會有任何錯誤" }
],
"answer": "a",
"explanation": "遞迴要安全,必須同時具備:基本情況(停止條件)、遞迴呼叫、以及每次呼叫都往基本情況靠近。這段程式碼有遞迴呼叫、也有讓 keys 變短,唯獨少了「keys 變空時要停下來」這個基本情況,所以會不斷遞迴到 keys 已經是空陣列、key1 變成 undefined 也還在繼續呼叫。",
"explanation": "遞迴要安全,必須同時具備:基本情況(停止條件)、遞迴呼叫、以及每次呼叫都往基本情況靠近。這段程式碼有遞迴呼叫、也有讓 keys 變短,唯獨少了「keys 變空時要停下來」這個基本情況:keys 變成空陣列後不會再繼續縮短,但呼叫仍然沒有停止,object 逐步變成 undefined,最終在 update 裡存取 object[key] 時因為 object 是 undefined 而拋出 TypeError,並不是傳統認知中「呼叫堆疊塞爆」那種無限遞迴。",
"verify": { "manual": "概念題:以具體缺陷程式碼取代抽象守則背誦,無可執行程式碼。" }
},
{
Expand Down
Loading