باختصار
درس عن الزلازل: تقرؤه، وتتدرّب ما شئت، ثم تؤدي اختبارًا قصيرًا لا يُتحقَّق فيه من كل إجابة إلا مرة واحدة. يُطبع كرّاسةً ويعمل على الشاشة وفي كتاب إلكتروني وعلى منصة تعليمية.
ما الذي ستنضده
الوحدة الرابعة من The Restless Planet، وهو مقرر تمهيدي في علوم الأرض: أربع صفحات عن الزلازل بقياس 176 × 250 مم، تفتتحها لوحة لصدع يعبر سهلًا جافًا. تقود القراءة إلى تدريب واختبار، مكتوبين في Markdown كتل :::exercise مع إجاباتها. تحمل الوحدة المطبوعة حلول التدريب وتحجب حلول الاختبار. وعلى الشاشة يُجاب عن الصفحات نفسها: يُتحقَّق من التدريب ويُعاد كما يشاء الطالب، أما كل سؤال في الاختبار فلا يقبل إلا تحقّقًا واحدًا، ولا يُعاد، ولا يُظهر حلّه إلا بعد استنفاد ذلك التحقّق. وتجمع لوحة بجانب الصفحات ما تسجّله منصة تعليمية: الدرجة، وهل اكتمل الاختبار، والنجاح أو الرسوب عند 70 %. وتُكتب الصفحات نفسها أيضًا EPUB ثابت التخطيط وحزمة SCORM 2004: مقرّر فيه الدرس والاختبار جزءان تقيّم المنصة كلًّا منهما على حدة.
تجيب هذه الوصفة عن
- كيف أتيح للقارئ حل التمارين والتحقق منها في نسخة HTML أو EPUB؟
- كيف أحوّل تمارين كتاب إلى مقرّر SCORM يرسل الدرجات إلى منصة تعلّم، مع اختبار لا يسمح إلا بتحقّق واحد؟
الجواب المختصر
// Practice: check, start again and see the solution as often as the learner likes.
const practice = {
id: 'practice',
number: { shape: 'disc', background: col('rust'), fontFamily: SANS, fontWeight: 700 },
instruction: { fontFamily: SANS, bold: true, fontSize: pt(10) },
options: { marker: 'circle' },
blank: { look: 'line', color: col('ink'), weight: pt(0.6) },
match: { look: 'dots', gap: em(3), color: col('rust') },
bank: { box: 'bank' },
marginTop: mm(5), marginBottom: mm(1), itemSpacing: em(0.15),
};
// The test: one check, no fresh start, the solution only once the check is spent. On paper
// the same questions print without their key.
const test = {
id: 'test', extends: 'practice', box: 'test',
number: { shape: 'square', background: col('slate') },
match: { color: col('slate') },
solutions: { placement: 'none' },
digital: { maxChecks: 1, reset: false, showSolution: 'noChecksLeft' },
};
const exercises = {
solutions: { placement: 'directive', layout: 'key' }, // the practice key where :::solutions is
// The pass mark the course's results are judged against (a SCORM package reports passed or
// failed with it); the HTML and EPUB editions carry it with the exercise data.
digital: { passingScore: 70 },
};
المكونات
- الميزات
- تمارين تفاعليةأنماط التمارينحزم SCORMالتمارينالاختيار والصواب والخطأالفراغات وبنوك الكلماتالتوصيل والتصنيفالترتيبالحلولطبعة HTML للقراءةالتصدير إلى EPUBصفحات افتتاح مصمَّمةسمات العنوانالترويسات وأرقام الصفحاتالترويسات بحسب دور الصفحةإطارات التنبيهلوحة ألوان دلاليةالأسطر الأرامل والأسطر اليتيمة والكلمات المعزولة
- الخطوط
- Source Serif 4, Archivo (SIL OFL 1.1)
- الأصول
fault-1500.jpg- A fault across a dry plain in late afternoon light (the module's opener) (Generated With Diffusion Models, أصلي)
طريقة التحضير
#1 · نمطان وقاعدتان
الشيفرة في الجواب المختصر. لا تقول التمارين إلا ما تسأل عنه، ويحدّد style الكتلة سلوكها. يحتفظ practice بقيم الكتاب الافتراضية، فيعرض شريطه «تحقّق» و«أظهر الحل» و«ابدأ من جديد» بلا حدود. ويوسّعه test بكتلة digital: maxChecks: 1 وreset: false وshowSolution: 'noChecksLeft'، فيعدّ الشريط حتى «لم يبقَ تحقّق» ولا يعرض الحل إلا عندئذ. وعلى الورق لا يطبع النمط نفسه حلوله (placement: 'none')، بينما تُطبع حلول التدريب حيث يقع سطر :::solutions.
#2 · محاولة محفوظة تعود من المخزن
// The platform keeps each exercise's answers under its key; this learner checked three
// questions of the test, with two mistakes, and left the ordering one to do.
const tests = doc.exercises.filter((ex) => ex.style === 'test');
const mistakes = { 'test-statements': 2, 'test-distance': 1 }; // a wrong slot in each
const attempt = (ex) => ({ checked: true, n: 1, v: Object.fromEntries(ex.slots.map((slot, i) => {
const right = exerciseSolutionValue(slot, ex);
const wrong = slot.kind === 'truefalse' ? 3 - right : '180'; // 25 s × 8 km, slipped
return [slot.key, mistakes[ex.anchor] === i ? wrong : right];
})) });
const kept = new Map(tests.filter((ex) => ex.anchor !== 'test-sequence')
.map((ex) => [ex.anchor, JSON.stringify(attempt(ex))]));
const saved = new Map();
const platform = { // the three calls of localStorage, over the platform's record
getItem: (key) => saved.get(key) ?? kept.get(key.split(':').pop()) ?? null,
setItem: (key, value) => saved.set(key, value),
removeItem: (key) => saved.delete(key),
};
يقرأ attachExercises إجابات كل تمرين بـgetItem ويكتبها بـsetItem، تحت storageKey ومرساة التمرين. وأي كائن فيه هذه الاستدعاءات الثلاثة يمكن أن يحل محل localStorage؛ والمنصة التعليمية تحفظ JSON نفسه في بيانات الطالب. وهذا الكائن يعيد ثلاثة أسئلة من الاختبار تم التحقّق منها، فيها خطآن (صحيح للعبارة 3، و180 كم للمسافة)، فتُفتح الصفحة كما يجدها طالب عائد: تلك الأسئلة مقفلة، وحلولها متاحة، وسؤال الترتيب لم يُحلّ بعد.
#3 · الإجابة على الصفحات وتلقّي كل تحقّق
const el = (tag, css, text) => Object.assign(document.createElement(tag),
{ textContent: text ?? '' }, { style: css });
const course = el('section', 'display: flex; flex-wrap: wrap; gap: 16px; justify-content: center;'
+ 'padding: 24px 16px 0; font: 13px/1.5 system-ui, sans-serif; color: #d9d5cc');
course.id = 'course';
const frame = el('div', 'width: min(700px, 94vw); height: 720px; overflow: auto;'
+ 'border-radius: 6px; background: #2a2e35');
frame.innerHTML = renderToHtml(doc, { mode: 'single', background: palette.paper, padding: 16,
resourceImageUrl: imageUrl });
const card = el('aside', 'width: min(300px, 94vw); padding: 14px 16px; border-radius: 6px;'
+ 'border: 1px solid #2c3038; background: #15181d');
const results = new Map();
function report(result) { // every check, fresh start and solution shown
if (result.style !== 'test') return;
results.set(result.key, result);
const all = [...results.values()];
const [right, total] = all.reduce(([r, n], x) => [r + x.right, n + x.total], [0, 0]);
const score = Math.round((100 * right) / total);
const done = all.length === tests.length && all.every((x) => x.checked || x.locked);
const verdict = !done ? t({ en: 'incomplete', es: 'sin terminar' })
: score >= exercises.digital.passingScore ? t({ en: 'passed', es: 'aprobado' })
: t({ en: 'failed', es: 'suspenso' });
card.replaceChildren(el('h2', 'margin: 0; font-size: 15px; color: #f4f1ea',
t({ en: 'What the platform hears', es: 'Lo que recibe la plataforma' })),
el('p', 'margin: 6px 0; font-size: 22px; color: #f4f1ea', `${score} % · ${verdict}`),
el('p', 'margin: 0 0 8px', t({ en: `${right} of ${total} answers right in the test, pass `
+ `mark ${exercises.digital.passingScore} %`, es: `${right} de ${total} respuestas bien en el `
+ `examen, aprueba con el ${exercises.digital.passingScore} %` })),
...all.map((x) => el('p', 'margin: 0; font-variant-numeric: tabular-nums',
`${t({ en: 'Exercise', es: 'Ejercicio' })} ${x.number} · `
+ `${x.right}/${x.total} · ${x.checked || x.locked ? t({ en: 'checked', es: 'comprobado' })
: t({ en: 'to do', es: 'por hacer' })}`)));
}
attachExercises(frame, { storageKey: 'restless-planet:4', store: platform, onResult: report });
course.append(frame, card);
document.getElementById('pages').before(course);
const first = frame.querySelector(`[data-pt-ex="${tests[0].id}"]`);
frame.scrollTop = first.getBoundingClientRect().top - frame.getBoundingClientRect().top - 120;
يكتب renderToHtml الصفحات وبيانات التمارين، ويضع attachExercises فوقها الحقول والخيارات والشريط. ويتلقى onResult كل تمرين عند تهيئته وبعد كل تحقّق وكل إعادة وكل حل يُعرض، ومعه الإجابات المقيَّمة الصحيحة (right) ومجموعها (total) وعدد مرات التحقّق المستعملة، وهل المحاولة مقفلة (locked). وتجمع اللوحة إجابات الاختبار كما يفعل مشغّل SCORM: الصحيح على المقيَّم في الاختبار كله، ومكتمل حين يكون كل سؤال قد تُحقِّق منه أو أُقفل، وناجح ابتداءً من passingScore. وتستعمل الصفحة dpi: 96، فيساوي المليمتر في الصفحة المطبوعة مليمترًا في CSS على الشاشة.
#4 · التمارين نفسها في EPUB
const epubFonts = (faces) => Promise.all(Object.entries(faces).flatMap(([family, specs]) =>
specs.map(async (spec) => {
const [weight, style, id] = [parseInt(spec, 10), spec.endsWith('i') ? 'italic' : 'normal',
fontsourceId(family)];
const res = await fetch(`${await fontsourceAt(id)}files/${id}-latin-${weight}-${style}.woff2`);
if (!res.ok) throw new Error(`Fontsource has no ${family} ${weight} ${style}`);
const bytes = new Uint8Array(await res.arrayBuffer());
return { family, weight, style, format: 'woff2', bytes };
})));
const metadata = { title: doc.metadata.title, subtitle: doc.metadata.subtitle, language: LANG,
modified: new Date('2026-10-31T00:00:00Z') }; // fixed: the same bytes on every run
const fonts = await epubFonts(FONTS);
const resourceBytes = (fileId) => ({ bytes: imageBytes(fileId), mediaType: 'image/jpeg' });
const epub = await renderToEpub([doc], { layout: 'fixed', metadata, fonts, resourceBytes });
const offer = (bytes, name, type) => {
const link = el('a', 'color: #d8a21a', `${name} · ${Math.round(bytes.length / 1024)} KB`);
link.href = URL.createObjectURL(new Blob([bytes], { type }));
link.download = name;
document.getElementById('pt-actions').append(link);
};
offer(epub, `${RECIPE}.epub`, 'application/epub+zip');
يحمل EPUB الثابت التخطيط بيانات التمارين والسكربت الذي يجعلها قابلة للإجابة: فنظام القراءة الذي يشغّل السكربتات يقدّم الحقول نفسها والحدود نفسها، والذي لا يشغّلها يحتفظ بالحلول التي يضيفها الكاتب. وتُمرَّر إليه الخطوط بايتاتٍ، وهي ملفات Fontsource نفسها التي حمّلتها الصفحة.
#5 · مقرّر SCORM والاختبار وحده
// The module's headings as postext-scorm reads them; the last one, the test, runs to the end.
const sections = treeLeaves(scormTree([{ markdown }], config)[0]);
const exam = sections.at(-1);
const items = [
{ title: t({ en: 'Lesson and practice', es: 'Lección y práctica' }), doc: 0,
range: { start: sections[0].range.start, end: exam.range.start } },
{ title: exam.title, doc: 0, range: exam.range }, // its score is the test's alone
];
const scorm = await renderToScorm([doc], { version: '2004', layout: 'fixed', items, metadata,
fonts, resourceBytes }); // the pass mark comes from exercises.digital.passingScore
offer(scorm, `${RECIPE}-scorm.zip`, 'application/zip');
يأخذ renderToScorm مستندات renderToEpub وخياراته، ومعها version الخاص بـ SCORM وitems المقرّر. كل item هو SCO، أي الجزء من المقرّر الذي تفتحه المنصة التعليمية وتقيّمه وحده، وتحسب كل SCO جميع التمارين التي تضمها. ويقرأ scormTree عناوين الوحدة مع المقطع الذي يغطيه كل عنوان من Markdown، فتمتد SCO الأولى من العنوان إلى الاختبار (وفيها يُقيَّم التدريب)، وتضم الثانية الاختبار وحده حتى النهاية. وتُبلغ كل منهما النجاح أو الرسوب وفق exercises.digital.passingScore. وفي الحزمة يقوم مشغّل بما يقوم به platform في هذه الصفحة: يحفظ الإجابات في سجل المنصة ويُرسل كل تحقّق، فيجد الطالب العائد الاختبار كما تركه.
الوصفة كاملة
// ═══ Postext Cookbook · Nº 179 · A course module with a test answered on screen ═══
// https://postext.dev/en/cookbook/self-checking-course-module
// Code: MIT · Text: original (CC BY 4.0) · Painting: generated with diffusion models
// Fonts: Source Serif 4, Archivo (SIL OFL 1.1) · Needs postext ≥ 1.65.0
import {
buildDocumentWithFonts, renderPageToCanvas, registerResourceImage, renderToHtml,
attachExercises, exerciseSolutionValue,
} from 'https://esm.sh/postext';
import { renderToEpub } from 'https://esm.sh/postext-epub';
import { renderToScorm, scormTree, treeLeaves } from 'https://esm.sh/postext-scorm';
const LANG = 'en'; // @lang: the language of the sample document ('en' | 'es')
const RECIPE = 'self-checking-course-module';
// ─── 1 · Design ─────────────────────────────────────────────────────────────
const palette = {
ink: '#1f2523', // text: a green-tinted near-black
rust: '#9c4420', // the course's accent: kicker, practice numbers, rules
slate: '#2f5266', // the test: its numbers and its frame
tint: '#f5efe4', // the safety box, the word bank
rule: '#d6cdbf', // hairlines
muted: '#646d69', // running heads, colophon
paper: '#ffffff',
};
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
const colorPalette = [
...Object.entries(palette).map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })),
{ id: 'main-color', name: 'rust (defaults)', value: { hex: palette.rust, model: 'hex' } },
];
const [TEXT, SANS] = ['Source Serif 4', 'Archivo'];
const [TOP, OUTER, LEAD] = [22, 21, 15.5]; // mm, mm, pt
const BAND = 100; // mm: the painting's depth from the trim, over 3 mm of bleed
// #region answer: practice and test from one Markdown: the test style limits each attempt
// Practice: check, start again and see the solution as often as the learner likes.
const practice = {
id: 'practice',
number: { shape: 'disc', background: col('rust'), fontFamily: SANS, fontWeight: 700 },
instruction: { fontFamily: SANS, bold: true, fontSize: pt(10) },
options: { marker: 'circle' },
blank: { look: 'line', color: col('ink'), weight: pt(0.6) },
match: { look: 'dots', gap: em(3), color: col('rust') },
bank: { box: 'bank' },
marginTop: mm(5), marginBottom: mm(1), itemSpacing: em(0.15),
};
// The test: one check, no fresh start, the solution only once the check is spent. On paper
// the same questions print without their key.
const test = {
يعمل ملف script.js المجمّع كما هو: الصقه في سكربت الوحدة (module) لأي صفحة، أو افتح الوصفة على CodePen. مجلد الوصفة على GitHub ↗ (يفتح في تبويب جديد)
تنويعات
#SCORM 1.2، أو النص المنساب
كثير من المنصات القديمة لا تستورد إلا SCORM 1.2، وفيها تصير درجة النجاح درجة الإتقان لكل SCO. ويُرسل layout: 'reflowable' النص بدل الصفحات المطبوعة، لمن يتعلّم على الهاتف.
-const scorm = await renderToScorm([doc], { version: '2004', layout: 'fixed', items, metadata,
+const scorm = await renderToScorm([doc], { version: '1.2', layout: 'reflowable', items, metadata,#تصحيح كل إجابة فور كتابتها
لاختبار قصير بلا زر «تحقّق»، اجعل check: 'instant' في exercises.digital. يلغي التصحيح الفوري حدّ مرات التحقّق، ويحتفظ نمط الاختبار بـshowSolution.
- digital: { passingScore: 70 },
+ digital: { passingScore: 70, check: 'instant' },أخطاء شائعة
أعطِ كل تمرين مقيَّم مرساة (:::exercise{#test-scales}). تُحفظ إجاباته تحت المرساة؛ وبدونها يُؤخذ المفتاح من موضع التمرين في المستند، فإذا أُضيف سؤال قبله أُعيدت إلى الطالب العائد الإجابات المحفوظة لسؤال آخر.
الحقوق
- الوصفة
- Ignacio Ferro
- النص
- نص أصلي, CC BY 4.0
- الصور
- A fault across a dry plain in late afternoon light (the module's opener) · Generated With Diffusion Models · أصلي
- الخطوط
- Source Serif 4 (SIL OFL 1.1) · Archivo (SIL OFL 1.1)
- الشيفرة
- MIT، مثل Postext
حرّر هذا الشرح ↗ (يفتح في تبويب جديد)مجلد الوصفة على GitHub ↗ (يفتح في تبويب جديد)


