@feelpp/antora-extensions 1.0.0-rc.3 → 1.0.0-rc.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +4 -2
- package/src/index.js +2 -0
- package/src/jupyter.js +277 -0
- package/src/listing.js +1 -1
- package/tests/fixtures/jupyter-contract/content/antora.yml +5 -0
- package/tests/fixtures/jupyter-contract/content/modules/ROOT/nav.adoc +2 -0
- package/tests/fixtures/jupyter-contract/content/modules/ROOT/pages/foundations/sample.adoc +28 -0
- package/tests/fixtures/jupyter-contract/content/modules/ROOT/pages/plain.adoc +3 -0
- package/tests/fixtures/jupyter-contract/expected.json +16 -0
- package/tests/fixtures/jupyter-contract/failures.json +30 -0
- package/tests/jupyter-contract-test.js +152 -0
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@feelpp/antora-extensions",
|
|
3
3
|
"private": false,
|
|
4
|
-
"version": "1.0.0-rc.
|
|
4
|
+
"version": "1.0.0-rc.5",
|
|
5
5
|
"description": "Antora Extensions by Feel++.",
|
|
6
6
|
"main": "src/index.js",
|
|
7
7
|
"repository": {
|
|
@@ -20,11 +20,13 @@
|
|
|
20
20
|
"homepage": "https://github.com/feelpp/feelpp-antora-extensions#readme",
|
|
21
21
|
"devDependencies": {
|
|
22
22
|
"chai": "^4.3.7",
|
|
23
|
-
"mocha": "^10.2.0",
|
|
24
23
|
"libnpmpublish": "^4.0.2",
|
|
24
|
+
"mocha": "^10.2.0",
|
|
25
25
|
"pacote": "^12.0.2"
|
|
26
26
|
},
|
|
27
27
|
"dependencies": {
|
|
28
|
+
"@asciidoctor/core": "2.2.9",
|
|
29
|
+
"asciidoctor-jupyter": "0.7.0",
|
|
28
30
|
"jsdom": "^20.0.3"
|
|
29
31
|
}
|
|
30
32
|
}
|
package/src/index.js
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
const {register: registerTooboxNavigation} = require('./toolbox-navigation.js')
|
|
2
2
|
const {register: registerListing} = require('./listing.js')
|
|
3
3
|
const {register: registerLunr} = require('./lunr.js')
|
|
4
|
+
const {register: registerJupyter} = require('./jupyter.js')
|
|
4
5
|
|
|
5
6
|
module.exports.register = (thisContext, args) => {
|
|
6
7
|
registerTooboxNavigation.call(thisContext, args)
|
|
7
8
|
registerListing.call(thisContext, args)
|
|
8
9
|
registerLunr.call(thisContext, args)
|
|
10
|
+
registerJupyter.call(thisContext, args)
|
|
9
11
|
}
|
package/src/jupyter.js
ADDED
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
const crypto = require('node:crypto')
|
|
2
|
+
const path = require('node:path').posix
|
|
3
|
+
|
|
4
|
+
const asciidoctor = require('@asciidoctor/core')()
|
|
5
|
+
const JupyterConverter = require('asciidoctor-jupyter')
|
|
6
|
+
const extensionPackage = require('../package.json')
|
|
7
|
+
const converterPackage = require('asciidoctor-jupyter/package.json')
|
|
8
|
+
|
|
9
|
+
asciidoctor.ConverterFactory.register(JupyterConverter, ['jupyter'])
|
|
10
|
+
|
|
11
|
+
const DEFAULT_FIGURE_CONTRACT = 'alt text, caption, labelled axes and units, and non-colour encoding'
|
|
12
|
+
|
|
13
|
+
function sha256 (value) {
|
|
14
|
+
return crypto.createHash('sha256').update(value).digest('hex')
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function parseHeaderAttributes (source) {
|
|
18
|
+
const attributes = {}
|
|
19
|
+
const lines = source.split(/\r?\n/)
|
|
20
|
+
let titleSeen = false
|
|
21
|
+
for (const line of lines) {
|
|
22
|
+
if (!titleSeen) {
|
|
23
|
+
if (line.startsWith('= ')) titleSeen = true
|
|
24
|
+
continue
|
|
25
|
+
}
|
|
26
|
+
if (line === '' || line.startsWith('//')) continue
|
|
27
|
+
const match = line.match(/^:([^:]+):(?:\s*(.*))?$/)
|
|
28
|
+
if (!match) break
|
|
29
|
+
attributes[match[1]] = match[2] || ''
|
|
30
|
+
}
|
|
31
|
+
return attributes
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function listAttribute (attributes, name) {
|
|
35
|
+
return (attributes[name] || '')
|
|
36
|
+
.split(',')
|
|
37
|
+
.map((value) => value.trim())
|
|
38
|
+
.filter(Boolean)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function resourceId ({ component, version, module, relative }, family = 'page') {
|
|
42
|
+
const prefix = version ? `${version}@${component}` : component
|
|
43
|
+
const familyMarker = family === 'page' ? '' : `${family}$`
|
|
44
|
+
return `${prefix}:${module}:${familyMarker}${relative}`
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function matchesInclude (relative, include) {
|
|
48
|
+
if (!include || include.length === 0) return true
|
|
49
|
+
return include.some((pattern) => {
|
|
50
|
+
const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replaceAll('*', '.*')
|
|
51
|
+
return new RegExp(`^${escaped}$`).test(relative)
|
|
52
|
+
})
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function isJupyterPage (page, options = {}) {
|
|
56
|
+
const source = page.contents.toString('utf8')
|
|
57
|
+
const attributes = parseHeaderAttributes(source)
|
|
58
|
+
return Object.hasOwn(attributes, 'page-jupyter') && matchesInclude(page.src.relative, options.include)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function notebookMetadata (page, source, attributes, options) {
|
|
62
|
+
const required = [
|
|
63
|
+
'page-course-language',
|
|
64
|
+
'page-course-priority',
|
|
65
|
+
'page-course-difficulty',
|
|
66
|
+
'page-course-duration-minutes',
|
|
67
|
+
'page-course-concepts',
|
|
68
|
+
'page-course-outcomes'
|
|
69
|
+
]
|
|
70
|
+
const missing = required.filter((name) => !attributes[name])
|
|
71
|
+
if (missing.length) {
|
|
72
|
+
throw new Error(`${resourceId(page.src)}: missing notebook metadata: ${missing.join(', ')}`)
|
|
73
|
+
}
|
|
74
|
+
if (attributes['page-course-language'] !== 'en') {
|
|
75
|
+
throw new Error(`${resourceId(page.src)}: generated notebooks must use English`)
|
|
76
|
+
}
|
|
77
|
+
const duration = Number.parseInt(attributes['page-course-duration-minutes'], 10)
|
|
78
|
+
if (!Number.isInteger(duration) || duration < 1) {
|
|
79
|
+
throw new Error(`${resourceId(page.src)}: invalid page-course-duration-minutes`)
|
|
80
|
+
}
|
|
81
|
+
return {
|
|
82
|
+
schema_version: 1,
|
|
83
|
+
source_kind: 'asciidoc',
|
|
84
|
+
source_resource_id: resourceId(page.src),
|
|
85
|
+
source_path: page.src.relative,
|
|
86
|
+
source_sha256: sha256(source),
|
|
87
|
+
source_page_url: page.pub.url,
|
|
88
|
+
language: 'en',
|
|
89
|
+
priority: attributes['page-course-priority'],
|
|
90
|
+
difficulty: attributes['page-course-difficulty'],
|
|
91
|
+
duration_minutes: duration,
|
|
92
|
+
concept_ids: listAttribute(attributes, 'page-course-concepts'),
|
|
93
|
+
outcomes: listAttribute(attributes, 'page-course-outcomes'),
|
|
94
|
+
execution_profile: attributes['page-course-execution-profile'] || 'fast',
|
|
95
|
+
accessibility: {
|
|
96
|
+
keyboard_only: true,
|
|
97
|
+
colour_alone_forbidden: true,
|
|
98
|
+
required_figure_contract: attributes['page-course-figure-contract'] || DEFAULT_FIGURE_CONTRACT
|
|
99
|
+
},
|
|
100
|
+
generator: {
|
|
101
|
+
asciidoctor_jupyter: converterPackage.version,
|
|
102
|
+
feelpp_antora_extensions: extensionPackage.version,
|
|
103
|
+
feelpp_asciidoctor_extensions: options.asciidoctorExtensionVersion || '1.0.0-rc.17'
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function normaliseMarkdown (source, page) {
|
|
109
|
+
return source
|
|
110
|
+
.replace(
|
|
111
|
+
/(^|\n)\+\n(\\[\s\S]*?)\n\+(?=\n|$)/g,
|
|
112
|
+
(_match, prefix, latex) => `${prefix}$$\n${latex}\n$$`
|
|
113
|
+
)
|
|
114
|
+
.replace(/\]\(attachment\$([^)]+)\)/g, (_match, target) => {
|
|
115
|
+
const relative = path.relative(path.dirname(page.src.relative), target)
|
|
116
|
+
return `](${relative || path.basename(target)})`
|
|
117
|
+
})
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function normaliseNotebook (notebook, page, source, attributes, options = {}) {
|
|
121
|
+
notebook.nbformat = 4
|
|
122
|
+
notebook.nbformat_minor = Math.max(notebook.nbformat_minor || 0, 5)
|
|
123
|
+
notebook.metadata = notebook.metadata || {}
|
|
124
|
+
notebook.metadata.kernelspec = notebook.metadata.kernelspec || {}
|
|
125
|
+
notebook.metadata.kernelspec.name = notebook.metadata.kernelspec.name || 'python3'
|
|
126
|
+
notebook.metadata.kernelspec.language = notebook.metadata.kernelspec.language || 'python'
|
|
127
|
+
notebook.metadata.kernelspec.display_name = notebook.metadata.kernelspec.display_name || 'Python 3'
|
|
128
|
+
notebook.metadata.course = notebookMetadata(page, source, attributes, options)
|
|
129
|
+
notebook.cells.forEach((cell, index) => {
|
|
130
|
+
let cellSource = Array.isArray(cell.source) ? cell.source.join('') : cell.source || ''
|
|
131
|
+
if (cell.cell_type === 'markdown') {
|
|
132
|
+
cellSource = normaliseMarkdown(cellSource, page)
|
|
133
|
+
cell.source = cellSource
|
|
134
|
+
}
|
|
135
|
+
cell.id = sha256(`${resourceId(page.src)}\0${index}\0${cell.cell_type}\0${cellSource}`).slice(0, 16)
|
|
136
|
+
if (cell.cell_type === 'code') {
|
|
137
|
+
cell.execution_count = null
|
|
138
|
+
cell.outputs = []
|
|
139
|
+
}
|
|
140
|
+
})
|
|
141
|
+
return notebook
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function generateNotebook (page, options = {}) {
|
|
145
|
+
const source = page.contents.toString('utf8')
|
|
146
|
+
const attributes = parseHeaderAttributes(source)
|
|
147
|
+
let notebook
|
|
148
|
+
try {
|
|
149
|
+
notebook = JSON.parse(asciidoctor.convert(source, {
|
|
150
|
+
backend: 'jupyter',
|
|
151
|
+
safe: 'safe',
|
|
152
|
+
standalone: true,
|
|
153
|
+
to_file: false,
|
|
154
|
+
attributes: {
|
|
155
|
+
'jupyter-language-name': attributes['jupyter-language-name'] || 'python',
|
|
156
|
+
'jupyter-language-version': attributes['jupyter-language-version'] || '3.12',
|
|
157
|
+
'jupyter-kernel-name': attributes['jupyter-kernel-name'] || 'python3',
|
|
158
|
+
'jupyter-kernel-language': attributes['jupyter-kernel-language'] || 'python'
|
|
159
|
+
}
|
|
160
|
+
}))
|
|
161
|
+
} catch (error) {
|
|
162
|
+
throw new Error(`${resourceId(page.src)}: notebook conversion failed: ${error.message}`)
|
|
163
|
+
}
|
|
164
|
+
normaliseNotebook(notebook, page, source, attributes, options)
|
|
165
|
+
return Buffer.from(`${JSON.stringify(notebook, null, 2)}\n`)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function attachmentRelative (page) {
|
|
169
|
+
return page.src.relative.replace(/\.adoc$/, '.ipynb')
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function manifestEntry (page, attachment, notebook) {
|
|
173
|
+
const metadata = notebook.metadata.course
|
|
174
|
+
const codeBlocks = notebook.cells
|
|
175
|
+
.map((cell, index) => ({ cell, index }))
|
|
176
|
+
.filter(({ cell }) => cell.cell_type === 'code')
|
|
177
|
+
.map(({ cell, index }) => ({
|
|
178
|
+
id: cell.id,
|
|
179
|
+
index,
|
|
180
|
+
source_sha256: sha256(Array.isArray(cell.source) ? cell.source.join('') : cell.source || ''),
|
|
181
|
+
dynamic: false,
|
|
182
|
+
required: true
|
|
183
|
+
}))
|
|
184
|
+
return {
|
|
185
|
+
source_resource_id: metadata.source_resource_id,
|
|
186
|
+
source_path: metadata.source_path,
|
|
187
|
+
source_sha256: metadata.source_sha256,
|
|
188
|
+
page_url: page.pub.url,
|
|
189
|
+
notebook_resource_id: resourceId(attachment.src, 'attachment'),
|
|
190
|
+
notebook_path: attachment.src.relative,
|
|
191
|
+
notebook_sha256: sha256(attachment.contents),
|
|
192
|
+
notebook_url: attachment.pub.url,
|
|
193
|
+
priority: metadata.priority,
|
|
194
|
+
difficulty: metadata.difficulty,
|
|
195
|
+
concept_ids: metadata.concept_ids,
|
|
196
|
+
outcomes: metadata.outcomes,
|
|
197
|
+
code_blocks: codeBlocks,
|
|
198
|
+
warnings: []
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function buildManifest (entries) {
|
|
203
|
+
return Buffer.from(`${JSON.stringify({
|
|
204
|
+
schema_version: 1,
|
|
205
|
+
artifact: 'asciidoc-notebook-manifest',
|
|
206
|
+
generator: {
|
|
207
|
+
name: extensionPackage.name,
|
|
208
|
+
version: extensionPackage.version
|
|
209
|
+
},
|
|
210
|
+
entries: [...entries].sort((a, b) => a.source_resource_id.localeCompare(b.source_resource_id))
|
|
211
|
+
}, null, 2)}\n`)
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function register ({ config = {} } = {}) {
|
|
215
|
+
const logger = this.getLogger('jupyter-extension')
|
|
216
|
+
const options = config.jupyter || {}
|
|
217
|
+
const generated = new Map()
|
|
218
|
+
|
|
219
|
+
this.on('contentClassified', ({ contentCatalog }) => {
|
|
220
|
+
const pages = contentCatalog.getPages().filter((page) => isJupyterPage(page, options))
|
|
221
|
+
.sort((a, b) => resourceId(a.src).localeCompare(resourceId(b.src)))
|
|
222
|
+
const entriesByComponent = new Map()
|
|
223
|
+
for (const page of pages) {
|
|
224
|
+
const contents = generateNotebook(page, options)
|
|
225
|
+
const attachment = contentCatalog.addFile({
|
|
226
|
+
contents,
|
|
227
|
+
src: {
|
|
228
|
+
component: page.src.component,
|
|
229
|
+
version: page.src.version,
|
|
230
|
+
module: page.src.module,
|
|
231
|
+
family: 'attachment',
|
|
232
|
+
relative: attachmentRelative(page)
|
|
233
|
+
}
|
|
234
|
+
})
|
|
235
|
+
const notebook = JSON.parse(contents.toString('utf8'))
|
|
236
|
+
const entry = manifestEntry(page, attachment, notebook)
|
|
237
|
+
const key = `${page.src.component}\0${page.src.version || ''}`
|
|
238
|
+
const group = entriesByComponent.get(key) || { page, entries: [] }
|
|
239
|
+
group.entries.push(entry)
|
|
240
|
+
entriesByComponent.set(key, group)
|
|
241
|
+
generated.set(page, attachment)
|
|
242
|
+
logger.info(`generated ${attachment.pub.url} from ${resourceId(page.src)}`)
|
|
243
|
+
}
|
|
244
|
+
for (const { page, entries } of entriesByComponent.values()) {
|
|
245
|
+
contentCatalog.addFile({
|
|
246
|
+
contents: buildManifest(entries),
|
|
247
|
+
src: {
|
|
248
|
+
component: page.src.component,
|
|
249
|
+
version: page.src.version,
|
|
250
|
+
module: 'ROOT',
|
|
251
|
+
family: 'attachment',
|
|
252
|
+
relative: 'generated/asciidoc-notebook-manifest.json'
|
|
253
|
+
}
|
|
254
|
+
})
|
|
255
|
+
}
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
this.on('documentsConverted', () => {
|
|
259
|
+
for (const [page, attachment] of generated) {
|
|
260
|
+
page.asciidoc.attributes['page-jupyter-url'] = attachment.pub.url
|
|
261
|
+
page.asciidoc.attributes['page-jupyter-status'] = 'generated'
|
|
262
|
+
}
|
|
263
|
+
})
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
module.exports = {
|
|
267
|
+
attachmentRelative,
|
|
268
|
+
buildManifest,
|
|
269
|
+
generateNotebook,
|
|
270
|
+
isJupyterPage,
|
|
271
|
+
manifestEntry,
|
|
272
|
+
normaliseMarkdown,
|
|
273
|
+
parseHeaderAttributes,
|
|
274
|
+
register,
|
|
275
|
+
resourceId,
|
|
276
|
+
sha256
|
|
277
|
+
}
|
package/src/listing.js
CHANGED
|
@@ -10,7 +10,7 @@ const template = require('./template')
|
|
|
10
10
|
/**
|
|
11
11
|
* @module listing-extension
|
|
12
12
|
*/
|
|
13
|
-
function register ({ config: { listing, ...unknownOptions } }) {
|
|
13
|
+
function register ({ config: { listing, jupyter: _jupyter, lunr: _lunr, ...unknownOptions } }) {
|
|
14
14
|
const packageName = "@feelpp/antora-listing-extension"
|
|
15
15
|
const logger = this.getLogger(packageName)
|
|
16
16
|
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
= Generated notebook sample
|
|
2
|
+
:page-jupyter:
|
|
3
|
+
:page-course-language: en
|
|
4
|
+
:page-course-concepts: DP-FND-01, DP-REP-01
|
|
5
|
+
:page-course-priority: P0
|
|
6
|
+
:page-course-difficulty: D1
|
|
7
|
+
:page-course-duration-minutes: 20
|
|
8
|
+
:page-course-outcomes: LO1, LO9
|
|
9
|
+
:page-course-execution-profile: fast
|
|
10
|
+
:jupyter-language-name: python
|
|
11
|
+
:jupyter-language-version: 3.12
|
|
12
|
+
:jupyter-kernel-name: python3
|
|
13
|
+
:jupyter-kernel-language: python
|
|
14
|
+
|
|
15
|
+
The page is the canonical explanation. Its generated notebook preserves this prose and the executable evidence below.
|
|
16
|
+
|
|
17
|
+
[stem]
|
|
18
|
+
++++
|
|
19
|
+
\widehat\mu = \frac{1}{n}\sum_{i=1}^{n}X_i.
|
|
20
|
+
++++
|
|
21
|
+
|
|
22
|
+
[source,python]
|
|
23
|
+
----
|
|
24
|
+
value = 6 * 7
|
|
25
|
+
print(value)
|
|
26
|
+
----
|
|
27
|
+
|
|
28
|
+
xref:attachment$foundations/sample.ipynb[Download the generated notebook].
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schema_version": 1,
|
|
3
|
+
"component": "executable-contract",
|
|
4
|
+
"version": "",
|
|
5
|
+
"module": "ROOT",
|
|
6
|
+
"source_relative_path": "foundations/sample.adoc",
|
|
7
|
+
"attachment_relative_path": "foundations/sample.ipynb",
|
|
8
|
+
"page_url": "/executable-contract/foundations/sample.html",
|
|
9
|
+
"attachment_url": "/executable-contract/_attachments/foundations/sample.ipynb",
|
|
10
|
+
"plain_page_generates_notebook": false,
|
|
11
|
+
"ui_contract": {
|
|
12
|
+
"requested_attribute": "jupyter",
|
|
13
|
+
"resolved_url_attribute": "jupyter-url",
|
|
14
|
+
"status_attribute": "jupyter-status"
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schema_version": 1,
|
|
3
|
+
"cases": [
|
|
4
|
+
{
|
|
5
|
+
"id": "duplicate-attachment",
|
|
6
|
+
"importance": "P0",
|
|
7
|
+
"expected": "reject two generated resources with the same Antora attachment ID"
|
|
8
|
+
},
|
|
9
|
+
{
|
|
10
|
+
"id": "converter-warning",
|
|
11
|
+
"importance": "P0",
|
|
12
|
+
"expected": "reject a required page when the converter reports an unsupported node"
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"id": "missing-converter",
|
|
16
|
+
"importance": "P0",
|
|
17
|
+
"expected": "fail with the package name and requested page resource ID"
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
"id": "unresolved-url",
|
|
21
|
+
"importance": "P0",
|
|
22
|
+
"expected": "do not expose a notebook URL or generated status"
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
"id": "nondeterministic-manifest",
|
|
26
|
+
"importance": "P1",
|
|
27
|
+
"expected": "reject timestamps, unstable ordering, or missing source/output digests"
|
|
28
|
+
}
|
|
29
|
+
]
|
|
30
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
const fs = require('node:fs')
|
|
2
|
+
const path = require('node:path')
|
|
3
|
+
const chai = require('chai')
|
|
4
|
+
const expect = chai.expect
|
|
5
|
+
const jupyter = require('../src/jupyter.js')
|
|
6
|
+
|
|
7
|
+
const fixtureDir = path.join(__dirname, 'fixtures', 'jupyter-contract')
|
|
8
|
+
|
|
9
|
+
describe('Jupyter generation contract fixture', () => {
|
|
10
|
+
it('defines a page-relative attachment and confirmed UI URL contract', () => {
|
|
11
|
+
const expected = JSON.parse(fs.readFileSync(path.join(fixtureDir, 'expected.json'), 'utf8'))
|
|
12
|
+
|
|
13
|
+
expect(expected.source_relative_path).to.equal('foundations/sample.adoc')
|
|
14
|
+
expect(expected.attachment_relative_path).to.equal('foundations/sample.ipynb')
|
|
15
|
+
expect(expected.attachment_url).to.equal(
|
|
16
|
+
'/executable-contract/_attachments/foundations/sample.ipynb'
|
|
17
|
+
)
|
|
18
|
+
expect(expected.plain_page_generates_notebook).to.equal(false)
|
|
19
|
+
expect(expected.ui_contract.resolved_url_attribute).to.equal('jupyter-url')
|
|
20
|
+
expect(expected.ui_contract.status_attribute).to.equal('jupyter-status')
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('contains marked and unmarked representative Antora pages', () => {
|
|
24
|
+
const pages = path.join(fixtureDir, 'content', 'modules', 'ROOT', 'pages')
|
|
25
|
+
const marked = fs.readFileSync(path.join(pages, 'foundations', 'sample.adoc'), 'utf8')
|
|
26
|
+
const plain = fs.readFileSync(path.join(pages, 'plain.adoc'), 'utf8')
|
|
27
|
+
|
|
28
|
+
expect(marked).to.contain(':page-jupyter:')
|
|
29
|
+
expect(marked).to.contain('[source,python]')
|
|
30
|
+
expect(plain).not.to.contain(':page-jupyter:')
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('inventories required generation failures before implementation', () => {
|
|
34
|
+
const failures = JSON.parse(fs.readFileSync(path.join(fixtureDir, 'failures.json'), 'utf8'))
|
|
35
|
+
const ids = failures.cases.map((item) => item.id)
|
|
36
|
+
|
|
37
|
+
expect(ids).to.deep.equal([
|
|
38
|
+
'duplicate-attachment',
|
|
39
|
+
'converter-warning',
|
|
40
|
+
'missing-converter',
|
|
41
|
+
'unresolved-url',
|
|
42
|
+
'nondeterministic-manifest'
|
|
43
|
+
])
|
|
44
|
+
expect(failures.cases.filter((item) => item.importance === 'P0')).to.have.length(4)
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('converts only marked pages and normalises notebook metadata deterministically', () => {
|
|
48
|
+
const pagesRoot = path.join(fixtureDir, 'content', 'modules', 'ROOT', 'pages')
|
|
49
|
+
const page = fixturePage('foundations/sample.adoc', pagesRoot)
|
|
50
|
+
const plain = fixturePage('plain.adoc', pagesRoot)
|
|
51
|
+
|
|
52
|
+
expect(jupyter.isJupyterPage(page)).to.equal(true)
|
|
53
|
+
expect(jupyter.isJupyterPage(plain)).to.equal(false)
|
|
54
|
+
const first = jupyter.generateNotebook(page)
|
|
55
|
+
const second = jupyter.generateNotebook(page)
|
|
56
|
+
expect(first.equals(second)).to.equal(true)
|
|
57
|
+
|
|
58
|
+
const notebook = JSON.parse(first.toString('utf8'))
|
|
59
|
+
expect(notebook.nbformat).to.equal(4)
|
|
60
|
+
expect(notebook.nbformat_minor).to.equal(5)
|
|
61
|
+
expect(notebook.metadata.kernelspec).to.include({
|
|
62
|
+
name: 'python3',
|
|
63
|
+
language: 'python',
|
|
64
|
+
display_name: 'Python 3'
|
|
65
|
+
})
|
|
66
|
+
expect(notebook.metadata.course).to.include({
|
|
67
|
+
source_kind: 'asciidoc',
|
|
68
|
+
source_path: 'foundations/sample.adoc',
|
|
69
|
+
priority: 'P0',
|
|
70
|
+
difficulty: 'D1',
|
|
71
|
+
language: 'en',
|
|
72
|
+
execution_profile: 'fast'
|
|
73
|
+
})
|
|
74
|
+
expect(notebook.metadata.course.concept_ids).to.deep.equal(['DP-FND-01', 'DP-REP-01'])
|
|
75
|
+
expect(notebook.cells.every((cell) => /^[0-9a-f]{16}$/.test(cell.id))).to.equal(true)
|
|
76
|
+
const markdown = notebook.cells
|
|
77
|
+
.filter((cell) => cell.cell_type === 'markdown')
|
|
78
|
+
.map((cell) => Array.isArray(cell.source) ? cell.source.join('') : cell.source)
|
|
79
|
+
.join('\n')
|
|
80
|
+
expect(markdown).to.contain('$$\n\\widehat\\mu')
|
|
81
|
+
expect(markdown).to.contain('](sample.ipynb)')
|
|
82
|
+
expect(markdown).not.to.match(/\n\+\n\\widehat/)
|
|
83
|
+
expect(markdown).not.to.contain('attachment$')
|
|
84
|
+
expect(notebook.cells.filter((cell) => cell.cell_type === 'code'))
|
|
85
|
+
.to.satisfy((cells) => cells.length === 1 && cells.every((cell) => cell.outputs.length === 0))
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('registers page-relative attachments, a manifest, and confirmed UI attributes', () => {
|
|
89
|
+
const pagesRoot = path.join(fixtureDir, 'content', 'modules', 'ROOT', 'pages')
|
|
90
|
+
const page = fixturePage('foundations/sample.adoc', pagesRoot)
|
|
91
|
+
const plain = fixturePage('plain.adoc', pagesRoot)
|
|
92
|
+
const files = []
|
|
93
|
+
const contentCatalog = {
|
|
94
|
+
getPages: () => [plain, page],
|
|
95
|
+
addFile: (file) => {
|
|
96
|
+
if (files.some((candidate) => JSON.stringify(candidate.src) === JSON.stringify(file.src))) {
|
|
97
|
+
throw new Error(`duplicate attachment: ${file.src.relative}`)
|
|
98
|
+
}
|
|
99
|
+
file.pub = {
|
|
100
|
+
url: `/executable-contract/_attachments/${file.src.relative}`
|
|
101
|
+
}
|
|
102
|
+
files.push(file)
|
|
103
|
+
return file
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
const handlers = {}
|
|
107
|
+
const context = {
|
|
108
|
+
getLogger: () => ({ info: () => {} }),
|
|
109
|
+
on: (event, handler) => { handlers[event] = handler }
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
jupyter.register.call(context, { config: {} })
|
|
113
|
+
handlers.contentClassified({ contentCatalog })
|
|
114
|
+
page.asciidoc = { attributes: {} }
|
|
115
|
+
plain.asciidoc = { attributes: {} }
|
|
116
|
+
handlers.documentsConverted()
|
|
117
|
+
|
|
118
|
+
expect(files.map((file) => file.src.relative)).to.deep.equal([
|
|
119
|
+
'foundations/sample.ipynb',
|
|
120
|
+
'generated/asciidoc-notebook-manifest.json'
|
|
121
|
+
])
|
|
122
|
+
expect(page.asciidoc.attributes).to.include({
|
|
123
|
+
'page-jupyter-status': 'generated',
|
|
124
|
+
'page-jupyter-url': '/executable-contract/_attachments/foundations/sample.ipynb'
|
|
125
|
+
})
|
|
126
|
+
expect(plain.asciidoc.attributes).to.deep.equal({})
|
|
127
|
+
|
|
128
|
+
const manifest = JSON.parse(files[1].contents.toString('utf8'))
|
|
129
|
+
expect(manifest.entries).to.have.length(1)
|
|
130
|
+
expect(manifest.entries[0].notebook_path).to.equal('foundations/sample.ipynb')
|
|
131
|
+
expect(manifest.entries[0].notebook_url).to.equal(
|
|
132
|
+
'/executable-contract/_attachments/foundations/sample.ipynb'
|
|
133
|
+
)
|
|
134
|
+
expect(manifest).not.to.have.property('generated_at')
|
|
135
|
+
})
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
function fixturePage (relative, root) {
|
|
139
|
+
return {
|
|
140
|
+
contents: fs.readFileSync(path.join(root, relative)),
|
|
141
|
+
src: {
|
|
142
|
+
component: 'executable-contract',
|
|
143
|
+
version: '',
|
|
144
|
+
module: 'ROOT',
|
|
145
|
+
family: 'page',
|
|
146
|
+
relative
|
|
147
|
+
},
|
|
148
|
+
pub: {
|
|
149
|
+
url: `/executable-contract/${relative.replace(/\.adoc$/, '.html')}`
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|