@feelpp/antora-extensions 1.0.0-rc.2 → 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/README.md CHANGED
@@ -1,3 +1,62 @@
1
1
  # Antora Extensions by Feel++
2
2
 
3
+ ![NPM Version](https://img.shields.io/npm/v/%40feelpp%2Fantora-extensions)
4
+
3
5
  A set of Antora extensions.
6
+
7
+ ## Extensions
8
+
9
+ ### Lunr Search Index
10
+
11
+ Automatically generates a Lunr.js compatible search index during the Antora build process.
12
+
13
+ #### Usage
14
+
15
+ Add the extension to your `site.yml`:
16
+
17
+ ```yaml
18
+ antora:
19
+ extensions:
20
+ - '@feelpp/antora-extensions'
21
+
22
+ config:
23
+ lunr:
24
+ indexFile: 'search-index.json' # Output filename (optional)
25
+ maxContentLength: 1000 # Max content per document (optional)
26
+ minContentLength: 50 # Min content to include document (optional)
27
+ debug: false # Enable debug logging (optional)
28
+ ```
29
+
30
+ The search index will be automatically generated at `build/site/search-index.json` after the site is built.
31
+
32
+ #### Features
33
+
34
+ - **Zero configuration**: Works out of the box with sensible defaults
35
+ - **Smart content extraction**: Focuses on main content, excludes navigation and footers
36
+ - **Configurable**: Customize content length limits and output location
37
+ - **Performance optimized**: Efficient processing during site generation
38
+ - **Error handling**: Graceful handling of malformed HTML or missing content
39
+
40
+ ## Listing
41
+
42
+ ### UI assumptions
43
+
44
+ This extension relies on a contract with the UI in order to minimize the configuration the user must perform to get the extension working.
45
+
46
+ #### Environment variable
47
+
48
+ When this extension is enabled, it sets the `SITE_LISTING_EXTENSION_ENABLED` environment variable to the value `true`.
49
+ This variable is available to the UI templates as `env.SITE_LISTING_EXTENSION_ENABLED`.
50
+ The existence of this variable informs the UI template that the listing extension is enabled.
51
+ When this variable is set, the UI is expected to add certain elements to support the extension.
52
+
53
+ #### Listing styles
54
+
55
+ This package provides additional CSS to style the listing results (`data/css/listing.css`).
56
+ The creator of the UI can either bundle those styles or reference them (for instance in `head-styles.hbs`):
57
+
58
+ ```hbs
59
+ {{#if env.SITE_LISTING_EXTENSION_ENABLED}}
60
+ <link rel="stylesheet" href="{{{uiRootPath}}}/css/listing.css">
61
+ {{/if}}
62
+ ```
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.2",
4
+ "version": "1.0.0-rc.5",
5
5
  "description": "Antora Extensions by Feel++.",
6
6
  "main": "src/index.js",
7
7
  "repository": {
@@ -11,12 +11,22 @@
11
11
  "keywords": [],
12
12
  "author": "Guillaume Grossetie <ggrossetie@yuzutech.fr>",
13
13
  "license": "MIT",
14
+ "scripts": {
15
+ "test": "mocha tests/*-test.js"
16
+ },
14
17
  "bugs": {
15
18
  "url": "https://github.com/feelpp/feelpp-antora-extensions/issues"
16
19
  },
17
20
  "homepage": "https://github.com/feelpp/feelpp-antora-extensions#readme",
18
21
  "devDependencies": {
22
+ "chai": "^4.3.7",
19
23
  "libnpmpublish": "^4.0.2",
24
+ "mocha": "^10.2.0",
20
25
  "pacote": "^12.0.2"
26
+ },
27
+ "dependencies": {
28
+ "@asciidoctor/core": "2.2.9",
29
+ "asciidoctor-jupyter": "0.7.0",
30
+ "jsdom": "^20.0.3"
21
31
  }
22
32
  }
package/src/index.js CHANGED
@@ -1,7 +1,11 @@
1
1
  const {register: registerTooboxNavigation} = require('./toolbox-navigation.js')
2
2
  const {register: registerListing} = require('./listing.js')
3
+ const {register: registerLunr} = require('./lunr.js')
4
+ const {register: registerJupyter} = require('./jupyter.js')
3
5
 
4
6
  module.exports.register = (thisContext, args) => {
5
7
  registerTooboxNavigation.call(thisContext, args)
6
8
  registerListing.call(thisContext, args)
9
+ registerLunr.call(thisContext, args)
10
+ registerJupyter.call(thisContext, args)
7
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
 
package/src/lunr.js ADDED
@@ -0,0 +1,145 @@
1
+ /**
2
+ * Antora extension for automatic Lunr.js search index generation
3
+ *
4
+ * This extension automatically generates a Lunr.js compatible search index
5
+ * after the site is published. It extracts content from all HTML files
6
+ * and creates a JSON index that can be used for client-side search.
7
+ */
8
+
9
+ const fs = require('fs')
10
+ const path = require('path')
11
+
12
+ // Lazy load JSDOM only when needed
13
+ let JSDOM
14
+ function loadJSDOM() {
15
+ if (!JSDOM) {
16
+ try {
17
+ JSDOM = require('jsdom').JSDOM
18
+ } catch (error) {
19
+ throw new Error('JSDOM is required for the Lunr extension. Install it with: npm install jsdom')
20
+ }
21
+ }
22
+ return JSDOM
23
+ }
24
+
25
+ function register({ config, playbook }) {
26
+ const logger = this.getLogger('lunr-extension')
27
+
28
+ this.once('sitePublished', (event) => {
29
+ logger.info('Generating Lunr.js search index...')
30
+
31
+ try {
32
+ // Get output directory from the playbook
33
+ const outputDir = playbook.output?.dir || 'build/site'
34
+ logger.info(`Output directory: ${outputDir}`)
35
+
36
+ generateSearchIndex(outputDir, config.lunr || {}, logger)
37
+ } catch (error) {
38
+ logger.error('Failed to generate search index:', error)
39
+ throw error
40
+ }
41
+ })
42
+ }
43
+
44
+ function generateSearchIndex(outputDir, config = {}, logger) {
45
+ const searchIndexFile = path.join(outputDir, config.indexFile || 'search-index.json')
46
+ const maxContentLength = config.maxContentLength || 1000
47
+ const minContentLength = config.minContentLength || 50
48
+
49
+ // Find all HTML files
50
+ const htmlFiles = walkDirectory(outputDir).filter(file => file.endsWith('.html'))
51
+ const documents = []
52
+ let id = 0
53
+
54
+ htmlFiles.forEach(filePath => {
55
+ try {
56
+ const html = fs.readFileSync(filePath, 'utf8')
57
+ const title = getTitle(html)
58
+ const content = extractTextContent(html)
59
+
60
+ // Skip empty content
61
+ if (!content || content.length < minContentLength) {
62
+ return
63
+ }
64
+
65
+ // Generate relative URL
66
+ const relativePath = path.relative(outputDir, filePath)
67
+ const url = '/' + relativePath.replace(/\\/g, '/').replace(/\/index\.html$/, '/')
68
+
69
+ documents.push({
70
+ id: ++id,
71
+ title: title,
72
+ content: content.substring(0, maxContentLength),
73
+ url: url
74
+ })
75
+
76
+ if (config.debug) {
77
+ logger.debug(`Indexed: ${title} (${url})`)
78
+ }
79
+ } catch (error) {
80
+ logger.warn(`Failed to process ${filePath}:`, error.message)
81
+ }
82
+ })
83
+
84
+ const searchIndex = {
85
+ documents: documents
86
+ }
87
+
88
+ // Write search index
89
+ fs.writeFileSync(searchIndexFile, JSON.stringify(searchIndex, null, 2))
90
+ logger.info(`Search index generated: ${documents.length} documents → ${searchIndexFile}`)
91
+ }
92
+
93
+ function extractTextContent(html) {
94
+ try {
95
+ const JSDOM = loadJSDOM()
96
+ const dom = new JSDOM(html)
97
+ const document = dom.window.document
98
+
99
+ // Remove script and style elements
100
+ const scripts = document.querySelectorAll('script, style, nav, .navbar, .footer')
101
+ scripts.forEach(el => el.remove())
102
+
103
+ // Get main content area
104
+ const main = document.querySelector('main, .main, .content, article')
105
+ const content = main || document.body
106
+
107
+ return content.textContent.trim().replace(/\s+/g, ' ')
108
+ } catch (error) {
109
+ return ''
110
+ }
111
+ }
112
+
113
+ function getTitle(html) {
114
+ try {
115
+ const JSDOM = loadJSDOM()
116
+ const dom = new JSDOM(html)
117
+ const titleEl = dom.window.document.querySelector('title, h1, .page-title')
118
+ return titleEl ? titleEl.textContent.trim() : 'Untitled'
119
+ } catch (error) {
120
+ return 'Untitled'
121
+ }
122
+ }
123
+
124
+ function walkDirectory(dir, fileList = []) {
125
+ if (!fs.existsSync(dir)) {
126
+ return fileList
127
+ }
128
+
129
+ const files = fs.readdirSync(dir)
130
+
131
+ files.forEach(file => {
132
+ const filePath = path.join(dir, file)
133
+ const stat = fs.statSync(filePath)
134
+
135
+ if (stat.isDirectory()) {
136
+ walkDirectory(filePath, fileList)
137
+ } else {
138
+ fileList.push(filePath)
139
+ }
140
+ })
141
+
142
+ return fileList
143
+ }
144
+
145
+ module.exports = { register }
@@ -0,0 +1,5 @@
1
+ name: executable-contract
2
+ title: Executable contract fixture
3
+ version: ~
4
+ nav:
5
+ - modules/ROOT/nav.adoc
@@ -0,0 +1,2 @@
1
+ * xref:foundations/sample.adoc[Generated notebook sample]
2
+ * xref:plain.adoc[Plain page]
@@ -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,3 @@
1
+ = Plain page
2
+
3
+ This page deliberately does not request a 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
+ }
@@ -0,0 +1,36 @@
1
+ const { describe, it } = require('mocha')
2
+ const { expect } = require('chai')
3
+ const fs = require('fs')
4
+ const path = require('path')
5
+
6
+ describe('Lunr extension', () => {
7
+ const { register } = require('../src/lunr.js')
8
+
9
+ it('should export a register function', () => {
10
+ expect(register).to.be.a('function')
11
+ })
12
+
13
+ it('should register with Antora context', () => {
14
+ let eventRegistered = false
15
+ const mockContext = {
16
+ getLogger: () => ({
17
+ info: () => {},
18
+ error: () => {},
19
+ debug: () => {},
20
+ warn: () => {}
21
+ }),
22
+ once: (event, handler) => {
23
+ if (event === 'sitePublished') {
24
+ eventRegistered = true
25
+ }
26
+ }
27
+ }
28
+
29
+ const config = {}
30
+ const playbook = { output: { dir: 'build/site' } }
31
+
32
+ register.call(mockContext, { config, playbook })
33
+
34
+ expect(eventRegistered).to.be.true
35
+ })
36
+ })