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
71 changes: 71 additions & 0 deletions design/author.md
Original file line number Diff line number Diff line change
Expand Up @@ -512,6 +512,77 @@ to 3,000. Nineteen things the code does not say by itself:
skipped, both given) wrote what was given and the default for what was skipped, and the run
with the author named in the request asked for the licence alone. Those eight packages play
too.
- **A text's own marks are said where a chat reads, and what a library cannot take apart is a
problem** (2026-11-12, #187). Haiku with a shell and the skill built each of the 32 content
types that need no media twice: 64 packages, all built, all passing the verifier with no
warning, and four played wrong with no word from either. Two were Drag and Drop, which has no
type file here. The other two were a text's marks:
- Fill in the Blanks, asked for the answers 1/2, 10:30 and km/h: `*10:30*` is the answer 10
with the tip 30 and `*km/h*` takes km or h, since a gap's first colon starts its tip and
what is before it is split at every slash (`parseSolution`, H5P.Blanks 1.14).
- Drag the Words with a colon in a gap's feedback: the feedback ends there, what follows is a
second tip, and `lex` (H5P.DragText 1.10) takes a gap's tips out of its word with one
`replace` of all of them, which with two takes out none: the learner drags
"hablo:primera persona: termina en -o.".

Measured where it costs most, Haiku as a chat with the skill's text and no tool, the type, the
author and "licence CC BY 4.0" given, each reply built, played, and the library's own key
(`getXAPIData`) held against what was asked for. With 0.9.3's text, of 30 first replies:
- none wrote the licence's version in its own field: 27 wrote `"license": "CC BY 4.0"`, which
the build refused with the list of names, and 3 wrote `CC BY` alone. `licenseVersion` was
named in the brief only. Pasted back, the list of names had all 12 replies drop the version.
- Fill in the Blanks: all 5 with `SKILL.md` alone wrote the answers as asked, and with the type
file 2 of 5 wrote `*1\/2*`, an escape nothing reads, and the other 3 other answers than
those asked for.
- Drag the Words: 2 of 5 with the type file wrote a colon in feedback.

What the library does take in an answer is an entity: `parseSolution` decodes each answer's
entities, and `:` holds no colon as the gap is split. Played, `*10:30*`,
`*km/h*` and `*1/2*` gave the key 10:30, km/h and 1/2, the four answers typed scored
4 of 4, and "10" was wrong. In Drag the Words a word written `7:45` is drawn 7:45 and
scores, and feedback with `:` shows a colon; the key in its xAPI statement keeps the
entity as written. In Mark the Words neither character is a mark. So:
- `SKILL.md` says a CC BY licence's version is a field of its own, `CC0 1.0` being one code
as it stands (cubic's review), and says of a
gap's `/` and `:` what they are and how an answer holds one; the two type files say it
with their own examples. The version is in the sentence and not in the example: with it in
the example, 3 of 7 replies gave a licence the user had named without a version one of 4.0,
and without, none of 5.
- A CC BY licence written with its version is a problem that names the licence it read and
where the version goes (`sourceOf`), the SPDX spelling too.
- A backslash before a slash or a colon in a gap's answers is a problem, the gap written out
as the library takes it (`LAYOUTS['H5P.Blanks']`). An answer that is a backslash and no
more is left alone.
- A gap of Drag the Words whose tip or feedback pattern matches twice is a problem, with the
word as the library would leave it (`LAYOUTS['H5P.DragText']`). A colon in the tip is
none: the tip runs on over it. Nor is one after `\+` or `\-` with no tip before it,
though it cuts the feedback there (cubic's review): `*habla\+Bien.:tercera persona*`
plays with the feedback "Bien." and the tip "tercera persona", a tip written after the
feedback, and a build cannot tell that from a colon meant for the feedback. The type file
says feedback holds no colon.

With them, of the first replies that wrote a source, in the same position: Fill in the Blanks
took the three answers as asked in 23 of 23, with the type file or without; Drag the Words
with its type file drew its words whole in 9 of 9, and without it in 9 of 12, the other three
refused for a field of their own making or `distractors` written as a list, and none built
and wrong; the version was in its own field in 47 of 47, and a licence skipped was `U` in 5 of
5. Three Mark the Words kept a sentence with 10:30 and km/h in it word for word, its words
marked as they stand; each was refused for the task description, which a chat without the
type file does not know the type requires. Over the
replies that had gone wrong, the build's problems pasted back in the build page's words: 8 of
8 right in one round, the Blanks with the answers asked for, where the first wording, which
told a chat to word the sentence another way, had two of four make two gaps of each answer.

`*10:30*` as written is not refused: it is a tip to the library, may be one to its author,
and a build cannot tell which. A note saying how each such gap was read, left to the chat,
did nothing: of 5 replies two sent the same source again, one wrote `\:`, one look-alike
characters, one asked. The type file handed back beside the licence's problem had 1 of 3
write the entities. A problem in the build's form saying which gap its author did not mean,
as a page could copy once they had looked, had 4 of 4; that page is not built. Run over the
94 real packages of the owner's corpus and the demo, 53 of these two types in them with 266
gaps, the two checks report nothing; over the 524 distinct sources these tests stored, 199 of
the two types with 765 gaps, only the 14 known to play wrong. Not tried: a text edited afterwards in H5P's own editor, whose rich-text
widget may write an entity back as its character.

Not built: editing an existing package, which a tool beside it does with `checkContent` as its
check; layout for Drag and Drop and Crossword; and any judgement of whether content is right, which
Expand Down
16 changes: 14 additions & 2 deletions packages/author/lib/build.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -157,10 +157,22 @@ export function sourceOf(input, names) {
}
if (tag) source.language = given.language
else report(at.language, 'expected a BCP 47 language tag, such as "en" or "de-AT"')
// A licence written with its version, "CC BY 4.0", as it is spoken of: H5P keeps the version in
// a field of its own. The names alone, which this problem used to list, had a chat drop the
// version to fit them.
const versioned = typeof given.license === 'string' ? /^CC[ -](BY(?:-(?:NC-SA|NC-ND|NC|ND|SA))?)[ -]v?(\d\.\d)$/i.exec(given.license.trim()) : null
const license = versioned ? `CC ${versioned[1].toUpperCase()}` : given.license
if (LICENSES.includes(given.license)) source.license = given.license
else report(at.license, `use one of ${LICENSES.map((each) => JSON.stringify(each)).join(', ')}; "U" when it is not known`)
else if (versioned) {
const written = JSON.stringify(given.license)
if (given.licenseVersion !== undefined) report(at.license, `${written} holds the version too, which licenseVersion names: write "license": "${license}"`)
else if (CC_VERSIONS.includes(versioned[2])) report(at.license, `${written} holds the version too: write "license": "${license}" and "licenseVersion": "${versioned[2]}"`)
else report(at.license, `${written} holds a version too: write "license": "${license}" and "licenseVersion", one of ${CC_VERSIONS.join(', ')}`)
source.license = license
if (given.licenseVersion === undefined && CC_VERSIONS.includes(versioned[2])) source.licenseVersion = versioned[2]
} else report(at.license, `use one of ${LICENSES.map((each) => JSON.stringify(each)).join(', ')}; "U" when it is not known`)
if (given.licenseVersion !== undefined) {
if (!String(given.license).startsWith('CC ') || given.license === 'CC0 1.0' || given.license === 'CC PDM') report(at.licenseVersion, 'is for a Creative Commons BY licence only')
if (!String(license).startsWith('CC ') || license === 'CC0 1.0' || license === 'CC PDM') report(at.licenseVersion, 'is for a Creative Commons BY licence only')
else if (!CC_VERSIONS.includes(given.licenseVersion)) report(at.licenseVersion, `use one of ${CC_VERSIONS.join(', ')}`)
else source.licenseVersion = given.licenseVersion
}
Expand Down
116 changes: 116 additions & 0 deletions packages/author/lib/types.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -642,6 +642,120 @@ const imageHotspotQuestion = {
}
}

/* ---------------------------------------------------------------- Fill in the Blanks */

/**
* The gaps of one block of text, found as H5P.Blanks 1.14 finds them (`handleBlanks` in its
* `js/blanks.js`): from an asterisk to the next, the tags between them taken out, an empty pair
* passed over, and the character after a gap never read as the start of the next. The library
* takes the tags out in one pass; here they are taken out until none is left, which differs only
* for a tag written inside a tag (`<<em>em>`), and leaves no tag that one pass would have joined.
* @param {string} question
* @returns {{ raw: string, content: string }[]} each gap as written, asterisks included, and what the library reads of it
*/
function blanksGaps(question) {
const gaps = []
let start = question.indexOf('*')
while (start !== -1) {
const end = question.indexOf('*', start + 1)
if (end === -1) break
let content = question.slice(start + 1, end)
for (let before = ''; before !== content; ) {
before = content
content = content.replaceAll(/<\/?[a-z]*\d?>/gi, '')
}
if (content.length) gaps.push({ raw: question.slice(start, end + 1), content })
start = question.indexOf('*', content.length ? end + 2 : end + 1)
}
return gaps
}

/**
* A backslash written to escape a slash or a colon in a gap's answer, `*1\/2*` or `*10\:30*`.
* Nothing in H5P.Blanks escapes either (`parseSolution`: the first colon starts the tip, and what
* is before it is split at every slash), so the answer ends in the backslash and what follows is
* another answer, or the tip: the package builds, plays, and takes "1\" or "2". What the library
* does take is an entity, which holds neither character as it is split and which it decodes in
* each answer: `*1&#47;2*` is the answer 1/2, and the problem writes the gap out that way. An
* answer that is a backslash and no more is left alone, being one a text may ask for.
* @type {Layout}
*/
const blanks = {
after(params, path, context) {
if (!Array.isArray(params?.questions)) return
params.questions.forEach((/** @type {unknown} */ question, /** @type {number} */ i) => {
if (typeof question !== 'string') return
for (const { raw, content } of blanksGaps(question)) {
const tipStart = content.indexOf(':')
const answers = (tipStart === -1 ? content : content.slice(0, tipStart)).split('/')
const escapes = answers.some((answer, at) => answer.endsWith('\\') && answer.trim() !== '\\' && (at < answers.length - 1 || tipStart !== -1))
if (!escapes) continue
// The gap as it was meant: a backslash's slash or colon part of the answer, up to the
// first colon no backslash stands before, where the tip starts.
let meant = ''
for (let at = 0; at < content.length; at++) {
const next = content[at + 1]
if (content[at] === '\\' && (next === '/' || next === ':')) {
meant += next === '/' ? '&#47;' : '&#58;'
at++
} else if (content[at] === ':') {
meant += content.slice(at)
break
} else meant += content[at]
}
const taken = answers.map((answer) => `"${answer.trim()}"`).join(' or ')
context.report(
`${path}.questions[${i}]`,
`\`${raw}\`: a backslash escapes nothing in a gap, so H5P.Blanks takes ${taken} here${tipStart === -1 ? '' : `, with the tip "${content.slice(tipStart + 1).trim()}"`}. ` +
`In an answer write \`/\` as \`&#47;\` and \`:\` as \`&#58;\`: \`*${meant}*\``
)
}
})
}
}

/* ---------------------------------------------------------------- Drag the Words */

/** A gap as a problem shows it: whole when short, its start otherwise. @param {string} text @param {number} most */
const shortened = (text, most) => (text.length > most ? `${text.slice(0, most - 1)}…` : text)

/**
* A gap H5P.DragText 1.10 cannot take apart. Its `lex` (in `dist/h5p-drag-text.js`) finds a gap's
* tip, from a colon up to a backslash, and its two feedbacks, from `\+` and `\-` up to a backslash
* or a colon, each with a pattern that may match more than once, and takes each out of the word
* with one `replace` of all its matches at once: with two, nothing is taken out. A colon in the
* feedback is the way to two: the feedback ends there, what follows is a second tip, and the
* learner drags the word with every tip still in it. A colon in the tip itself is none, the tip
* running on over it. The word is worked out here as the library does, to show what would be
* dragged.
* @type {Layout}
*/
const dragText = {
after(params, path, context) {
if (typeof params?.textField !== 'string') return
let gap = 0
for (const part of params.textField.replace(/(\r\n|\n|\r)/gm, '<br/>').split(/(\*.*?\*)/)) {
if (!part.startsWith('*') || !part.endsWith('*')) continue
gap++
const tips = part.match(/(:([^\\*]+))/g) ?? []
const right = part.match(/(\\\+([^\\*:]+))/g) ?? []
const wrong = part.match(/(\\-([^\\*:]+))/g) ?? []
if (tips.length < 2 && right.length < 2 && wrong.length < 2) continue

@cubic-dev-ai cubic-dev-ai Bot Nov 12, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: *word\+Feedback:extra* passes this guard because H5P finds only one tips match, yet its lexer treats :extra as the tip and truncates the correct feedback to Feedback. Detect a colon after \+ or \- even when it is the only colon, and cover this case in the layout tests.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At packages/author/lib/types.mjs, line 743:

<comment>`*word\+Feedback:extra*` passes this guard because H5P finds only one `tips` match, yet its lexer treats `:extra` as the tip and truncates the correct feedback to `Feedback`. Detect a colon after `\+` or `\-` even when it is the only colon, and cover this case in the layout tests.</comment>

<file context>
@@ -642,6 +642,120 @@ const imageHotspotQuestion = {
+      const tips = part.match(/(:([^\\*]+))/g) ?? []
+      const right = part.match(/(\\\+([^\\*:]+))/g) ?? []
+      const wrong = part.match(/(\\-([^\\*:]+))/g) ?? []
+      if (tips.length < 2 && right.length < 2 && wrong.length < 2) continue
+      let word = part.slice(1, -1)
+      for (const matches of [tips, right, wrong]) if (matches.length) word = word.replace(String(matches), '')
</file context>
Fix with cubic

let word = part.slice(1, -1)
for (const matches of [tips, right, wrong]) if (matches.length) word = word.replace(String(matches), '')
const [why, write] =
tips.length > 1 && /\\[+-][^*]*:/.test(part)
? ['its feedback holds a `:`, where H5P.DragText ends the feedback and starts another tip', 'Write `:` once in a gap, before the tip, and none in the feedback']
: tips.length > 1
? ['`:` starts a tip more than once in it', 'Write `:` once in a gap, before the tip']
: right.length > 1
? ['`\\+` stands more than once in it', 'Write `\\+` once in a gap, before the feedback for the right word']
: ['`\\-` stands more than once in it', 'Write `\\-` once in a gap, before the feedback for a wrong word']
context.report(`${path}.textField`, `gap ${gap}, \`${shortened(part, 60)}\`: ${why}, and it then leaves them in the word: the learner would drag "${shortened(word.replace(/\s+$/, ''), 70)}". ${write}`)
}
}
}

/* ---------------------------------------------------------------- Video */

/** @type {Layout} */
Expand All @@ -650,6 +764,8 @@ const video = { optional: ['visuals.poster'] }
/** @type {Record<string, Layout>} */
export const LAYOUTS = {
'H5P.QuestionSet': questionSet,
'H5P.Blanks': blanks,
'H5P.DragText': dragText,
'H5P.InteractiveBook': interactiveBook,
'H5P.CoursePresentation': coursePresentation,
'H5P.InteractiveVideo': interactiveVideo,
Expand Down
18 changes: 18 additions & 0 deletions packages/author/tests/build.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -329,6 +329,24 @@ describe('any content type in the pack', () => {
expect.stringMatching(/^license: use one of/),
'authors[0].role: use one of Author, Editor, Licensee, Originator'
])
// A licence written with its version, as it is spoken of: the problem says where the version
// goes, and names the licence it read. The list of names alone had a chat drop the version.
const blanks = { type: 'H5P.Blanks', params: { questions: ['The *Danube* flows east.'] } }
expect(await problems({ ...META, ...blanks, license: 'CC BY 4.0' })).toEqual(['license: "CC BY 4.0" holds the version too: write "license": "CC BY" and "licenseVersion": "4.0"'])
expect(await problems({ ...META, ...blanks, license: 'cc-by-sa-4.0' })).toEqual(['license: "cc-by-sa-4.0" holds the version too: write "license": "CC BY-SA" and "licenseVersion": "4.0"'])
expect(await problems({ ...META, ...blanks, license: 'CC BY-NC 5.0' })).toEqual(['license: "CC BY-NC 5.0" holds a version too: write "license": "CC BY-NC" and "licenseVersion", one of 4.0, 3.0, 2.5, 2.0, 1.0'])
expect(await problems({ ...META, ...blanks, license: 'CC BY 4.0', licenseVersion: '4.0' })).toEqual(['license: "CC BY 4.0" holds the version too, which licenseVersion names: write "license": "CC BY"'])
expect(await problems({ ...META, ...blanks, license: 'CC-BY-4.0', licenseVersion: '9' })).toEqual([
'license: "CC-BY-4.0" holds the version too, which licenseVersion names: write "license": "CC BY"',
'licenseVersion: use one of 4.0, 3.0, 2.5, 2.0, 1.0'
])
expect((await build({ ...META, ...blanks, license: 'CC BY', licenseVersion: '4.0' })).manifest).toMatchObject({ license: 'CC BY', licenseVersion: '4.0' })
// CC0 1.0 is one code, version and all, and no versioned CC BY.
expect((await build({ ...META, ...blanks, license: 'CC0 1.0' })).manifest).toMatchObject({ license: 'CC0 1.0' })
expect(await problems({ ...META, ...blanks, license: 'CC0', licenseVersion: '1.0' })).toEqual([
expect.stringMatching(/^license: use one of "U", "CC BY", .*"CC0 1\.0"/),
'licenseVersion: is for a Creative Commons BY licence only'
])
expect(await problems({
...META,
type: 'H5P.TrueFalse',
Expand Down
Loading
Loading