@lincyaw/render-notebook 1.0.0-rc.0 → 1.0.0-rc.2

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
@@ -2,7 +2,7 @@
2
2
 
3
3
  Presmium's notebook renderer: writes the IR as a Jupyter notebook (nbformat 4). Narration becomes Markdown cells, code in the kernel's language becomes code cells with their recorded outputs, and components show their fallback.
4
4
 
5
- Part of [Presmium](https://github.com/Lincyaw/presmium), which turns lectures written in MyST Markdown into web pages, slides, Jupyter notebooks and narrated lectures.
5
+ Part of [Presmium](https://github.com/Lincyaw/presmium), which compiles canonical lecture.json teaching documents into web pages, slides, Jupyter notebooks and narrated playback.
6
6
 
7
7
  ## Install
8
8
 
@@ -12,11 +12,11 @@ pnpm add -D @lincyaw/cli @lincyaw/render-notebook
12
12
 
13
13
  ## Configuration
14
14
 
15
- In the headmatter, `renderers.notebook`: `kernel` (default `python3`) and `webUrl`, where components the notebook cannot show link to (by default `../web/` when the web pages are built too).
15
+ In `lecture.json`, `metadata.renderers.notebook`: `kernel` (default `python3`) and `webUrl`, where components the notebook cannot show link to (by default `../web/` when the web pages are built too).
16
16
 
17
17
  ## Documentation
18
18
 
19
- - [Author guide](https://github.com/Lincyaw/presmium/blob/main/site/guide/lecture.en.md)
19
+ - [Author guide](https://github.com/Lincyaw/presmium/blob/main/site/guide/locales/en/lecture.json)
20
20
 
21
21
  ## License
22
22
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lincyaw/render-notebook",
3
- "version": "1.0.0-rc.0",
3
+ "version": "1.0.0-rc.2",
4
4
  "description": "Presmium's notebook renderer: writes the IR as a Jupyter notebook (nbformat 4)",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -35,8 +35,8 @@
35
35
  "vfile": "^5.3.7"
36
36
  },
37
37
  "peerDependencies": {
38
- "@lincyaw/kernel": "^1.0.0-rc.0",
39
- "@lincyaw/web": "^1.0.0-rc.0"
38
+ "@lincyaw/kernel": "^1.0.0-rc.2",
39
+ "@lincyaw/web": "^1.0.0-rc.2"
40
40
  },
41
41
  "peerDependenciesMeta": {
42
42
  "@lincyaw/web": {
@@ -44,8 +44,8 @@
44
44
  }
45
45
  },
46
46
  "devDependencies": {
47
- "@lincyaw/cli": "1.0.0-rc.0",
48
- "@lincyaw/kernel": "1.0.0-rc.0",
49
- "@lincyaw/web": "1.0.0-rc.0"
47
+ "@lincyaw/cli": "1.0.0-rc.2",
48
+ "@lincyaw/kernel": "1.0.0-rc.2",
49
+ "@lincyaw/web": "1.0.0-rc.2"
50
50
  }
51
51
  }
@@ -9,7 +9,8 @@ export interface MarkdownText {
9
9
  degraded: string[]
10
10
  }
11
11
 
12
- export function toMarkdown(mdast: MdastNode, urls?: LowerOptions['urls']): MarkdownText {
12
+ /** A Markdown tree as Markdown a notebook shows: MyST lowered to what Jupyter renders, then written block by block. */
13
+ export function lowerToMarkdown(mdast: MdastNode, urls?: LowerOptions['urls']): MarkdownText {
13
14
  const lowered = lowerMyst(mdast, { urls, htmlUrls: true })
14
15
  const problems: string[] = []
15
16
  const parts = (lowered.mdast.children ?? []).map((block) => {
@@ -0,0 +1,18 @@
1
+ import type { Locale, MdastNode } from '@lincyaw/kernel'
2
+
3
+ function text(value: string): MdastNode {
4
+ return { type: 'text', value }
5
+ }
6
+
7
+ export const NOTEBOOK_MESSAGES = {
8
+ 'zh-CN': {
9
+ web: '网页版',
10
+ note: (name: MdastNode, web: MdastNode): MdastNode[] => [text('交互组件 '), name, text(' 需要在'), web, text('中查看。')],
11
+ },
12
+ 'en': {
13
+ web: 'the web version',
14
+ note: (name: MdastNode, web: MdastNode): MdastNode[] => [text('The interactive component '), name, text(' can only be used in '), web, text('.')],
15
+ },
16
+ } satisfies Record<Locale, unknown>
17
+
18
+ export type NotebookMessages = (typeof NOTEBOOK_MESSAGES)[Locale]
@@ -1,4 +1,4 @@
1
- import { errorMessage, isRecord, languageId, withProp } from '@lincyaw/kernel'
1
+ import { errorMessage, hasRendererExtension, isRecord, languageId, withPublishedAssets } from '@lincyaw/kernel'
2
2
  import type {
3
3
  CodeNode,
4
4
  ComponentNode,
@@ -9,20 +9,23 @@ import type {
9
9
  Locale,
10
10
  MdastNode,
11
11
  RecordedRun,
12
- RendererExtension,
12
+ BoundRendererExtension,
13
13
  RunOutput,
14
14
  SourcePos,
15
15
  Support,
16
16
  } from '@lincyaw/kernel'
17
- import { toMarkdown } from './markdown.ts'
17
+ import { lowerToMarkdown } from './markdown.ts'
18
+ import { NOTEBOOK_MESSAGES } from './messages.ts'
19
+ import type { NotebookMessages } from './messages.ts'
18
20
 
19
- export interface KernelSpec {
21
+ /** The Jupyter kernel a notebook names (nbformat's `kernelspec`). */
22
+ export interface JupyterKernelSpec {
20
23
  name: string
21
24
  display_name: string
22
25
  language: string
23
26
  }
24
27
 
25
- export const DEFAULT_KERNEL: KernelSpec = { name: 'python3', display_name: 'Python 3', language: 'python' }
28
+ export const DEFAULT_JUPYTER_KERNEL: JupyterKernelSpec = { name: 'python3', display_name: 'Python 3', language: 'python' }
26
29
 
27
30
  export type NotebookCellInput = string | {
28
31
  cell_type: 'markdown' | 'code' | 'raw'
@@ -48,12 +51,12 @@ export interface NotebookCell {
48
51
  export interface Notebook {
49
52
  nbformat: 4
50
53
  nbformat_minor: 5
51
- metadata: { kernelspec: KernelSpec, language_info: { name: string }, title?: string }
54
+ metadata: { kernelspec: JupyterKernelSpec, language_info: { name: string }, title?: string }
52
55
  cells: NotebookCell[]
53
56
  }
54
57
 
55
58
  export interface NotebookOptions {
56
- kernel: KernelSpec
59
+ jupyterKernel: JupyterKernelSpec
57
60
  locale: Locale
58
61
  webLink?: (section: IRSection) => string | undefined
59
62
  extensionFor: (node: ComponentNode) => ComponentRenderer | undefined
@@ -70,47 +73,39 @@ export interface NotebookResult {
70
73
 
71
74
  type ComponentRenderer = (node: ComponentNode, block: IRBlock) => unknown
72
75
 
73
- const MESSAGES = {
74
- 'zh-CN': {
75
- web: '网页版',
76
- note: (name: MdastNode, web: MdastNode): MdastNode[] => [text('交互组件 '), name, text(' 需要在'), web, text('中查看。')],
77
- },
78
- 'en': {
79
- web: 'the web version',
80
- note: (name: MdastNode, web: MdastNode): MdastNode[] => [text('The interactive component '), name, text(' can only be used in '), web, text('.')],
81
- },
82
- } satisfies Record<Locale, unknown>
83
-
84
76
  function text(value: string): MdastNode {
85
77
  return { type: 'text', value }
86
78
  }
87
79
 
88
- export function support(node: IRNode, extensions: RendererExtension[]): Support {
89
- return node.type === 'component' && !extensions.some(e => (!node.plugin || e.plugin === node.plugin) && e.components?.[node.name]) ? 'degraded' : 'full'
80
+ /** How well a notebook shows a node: a component without a notebook extension is degraded. */
81
+ const documentLayout = (node: IRNode): boolean => node.type === 'component' && node.plugin === 'presmium.layout' && ['PmColumns', 'PmStack', 'PmTabs'].includes(node.name)
82
+ export function notebookSupport(node: IRNode, extensions: BoundRendererExtension[]): Support {
83
+ if (documentLayout(node)) return 'full'
84
+ return node.type === 'component' && !hasRendererExtension(node, extensions) ? 'degraded' : 'full'
90
85
  }
91
86
 
92
87
  export function renderNotebook(doc: IRDocument, options: NotebookOptions): NotebookResult {
93
- const { kernel } = options
88
+ const { jupyterKernel } = options
94
89
  const out: NotebookResult = {
95
- notebook: { nbformat: 4, nbformat_minor: 5, metadata: { kernelspec: kernel, language_info: { name: kernel.language } }, cells: [] },
90
+ notebook: { nbformat: 4, nbformat_minor: 5, metadata: { kernelspec: jupyterKernel, language_info: { name: jupyterKernel.language } }, cells: [] },
96
91
  skipped: [],
97
92
  problems: [],
98
93
  degraded: [],
99
94
  unlinked: 0,
100
95
  }
101
96
  if (doc.meta.title) out.notebook.metadata.title = doc.meta.title
102
- const ids = new Set<string>()
97
+ const cellCounts = new Map<string, number>()
103
98
  const executions = { count: 0 }
104
- for (const section of doc.sections) renderSection(section, options, out, ids, executions)
99
+ for (const section of doc.sections) renderSection(section, options, out, cellCounts, executions)
105
100
  return out
106
101
  }
107
102
 
108
- function renderSection(section: IRSection, options: NotebookOptions, out: NotebookResult, ids: Set<string>, executions: { count: number }): void {
103
+ function renderSection(section: IRSection, options: NotebookOptions, out: NotebookResult, cellCounts: Map<string, number>, executions: { count: number }): void {
109
104
  let pending: { parts: string[], blocks: string[] } | undefined
110
105
 
111
106
  const cell = (type: NotebookCell['cell_type'], source: string | string[], blocks: string[], extra: Partial<NotebookCell> = {}) => {
112
107
  out.notebook.cells.push({
113
- id: cellId(section, ids),
108
+ id: cellId(section, cellCounts),
114
109
  cell_type: type,
115
110
  ...extra,
116
111
  metadata: { ...extra.metadata, presmium: { section: section.id, blocks } },
@@ -123,7 +118,7 @@ function renderSection(section: IRSection, options: NotebookOptions, out: Notebo
123
118
  }
124
119
  const written = (mdast: MdastNode, block?: IRBlock): string => {
125
120
  const { media } = options
126
- const { text, problems, degraded } = toMarkdown(mdast, media && block ? url => media(url, block.source) : undefined)
121
+ const { text, problems, degraded } = lowerToMarkdown(mdast, media && block ? url => media(url, block.source) : undefined)
127
122
  const where = { section: section.id, block: block?.id ?? 'title', source: block?.source ?? section.source }
128
123
  for (const problem of problems) out.problems.push({ message: `${where.section}#${where.block}: ${problem}`, source: where.source })
129
124
  if (degraded.length) out.degraded.push({ ...where, nodes: degraded })
@@ -141,7 +136,7 @@ function renderSection(section: IRSection, options: NotebookOptions, out: Notebo
141
136
  const shown = node.echo !== false
142
137
  const caption = codeCaption(node)
143
138
  if (caption && shown) markdown(caption, block)
144
- if (languageId(node.lang) === languageId(options.kernel.language)) {
139
+ if (languageId(node.lang) === languageId(options.jupyterKernel.language)) {
145
140
  flush()
146
141
  const runs = node.outputs ?? []
147
142
  const count = runs.length ? ++executions.count : null
@@ -163,10 +158,19 @@ function renderSection(section: IRSection, options: NotebookOptions, out: Notebo
163
158
 
164
159
  const component = (original: ComponentNode, block: IRBlock) => {
165
160
  const node = published(original, block, options)
161
+ if (documentLayout(node)) {
162
+ if (typeof node.props.title === 'string' && node.props.title) markdown({ type: 'heading', depth: 3, children: [text(node.props.title)] }, block)
163
+ for (const [index, child] of (node.children ?? []).entries()) {
164
+ const title = node.name === 'PmTabs' && Array.isArray(node.props.tabTitles) ? node.props.tabTitles[index] : undefined
165
+ if (typeof title === 'string' && title) markdown({ type: 'heading', depth: 4, children: [text(title)] }, child)
166
+ renderBlock(child)
167
+ }
168
+ return
169
+ }
166
170
  const render = options.extensionFor(node)
167
171
  if (render) {
168
172
  try {
169
- const cells = cellInputs(render(node, block))
173
+ const cells = toCellInputs(render(node, block))
170
174
  if (cells) {
171
175
  flush()
172
176
  for (const input of cells) {
@@ -190,7 +194,7 @@ function renderSection(section: IRSection, options: NotebookOptions, out: Notebo
190
194
  if (node.fallback) return markdown(node.fallback, block)
191
195
  const link = options.webLink?.(section)
192
196
  if (!link) out.unlinked++
193
- markdown(webNote(node, MESSAGES[options.locale], link), block)
197
+ markdown(webNote(node, NOTEBOOK_MESSAGES[options.locale], link), block)
194
198
  const reason = render ? `the notebook extension of ${node.name} rendered nothing and it has no fallback` : `component ${node.name} has no notebook extension or fallback`
195
199
  out.skipped.push({ section: section.id, block: block.id, reason: `${reason}; a note points to the web version` })
196
200
  node.children?.forEach(renderBlock)
@@ -210,13 +214,11 @@ function renderSection(section: IRSection, options: NotebookOptions, out: Notebo
210
214
 
211
215
  function published(node: ComponentNode, block: IRBlock, options: NotebookOptions): ComponentNode {
212
216
  const { media } = options
213
- if (!node.assets || !media) return node
214
- let props = node.props
215
- for (const [path, url] of Object.entries(node.assets)) props = withProp(props, path, media(url, block.source) ?? url)
216
- return { ...node, props }
217
+ return media ? withPublishedAssets(node, url => media(url, block.source)) : node
217
218
  }
218
219
 
219
- function cellInputs(value: unknown): NotebookCellInput[] | undefined {
220
+ /** The cells a notebook extension returned; throws when one is not a cell. */
221
+ function toCellInputs(value: unknown): NotebookCellInput[] | undefined {
220
222
  if (value === undefined || value === null) return undefined
221
223
  const cells = Array.isArray(value) ? value : [value]
222
224
  for (const c of cells) {
@@ -240,7 +242,7 @@ function codeCaption(node: CodeNode): MdastNode | undefined {
240
242
  return children.length ? { type: 'paragraph', children } : undefined
241
243
  }
242
244
 
243
- function webNote(node: ComponentNode, t: (typeof MESSAGES)[Locale], url?: string): MdastNode {
245
+ function webNote(node: ComponentNode, t: NotebookMessages, url?: string): MdastNode {
244
246
  const web = url ? { type: 'link', url, children: [text(t.web)] } : text(t.web)
245
247
  return { type: 'blockquote', children: [{ type: 'paragraph', children: t.note({ type: 'inlineCode', value: node.name }, web) }] }
246
248
  }
@@ -269,10 +271,9 @@ function lines(text: string): string[] {
269
271
  return text ? text.split(/(?<=\n)/) : []
270
272
  }
271
273
 
272
- function cellId(section: IRSection, ids: Set<string>): string {
274
+ function cellId(section: IRSection, counts: Map<string, number>): string {
273
275
  const base = section.id.replace(/[^\w-]/g, '').slice(0, 48) || `section-${section.index}`
274
- let n = 1
275
- while (ids.has(`${base}-${n}`)) n++
276
- ids.add(`${base}-${n}`)
276
+ const n = (counts.get(base) ?? 0) + 1
277
+ counts.set(base, n)
277
278
  return `${base}-${n}`
278
279
  }
@@ -1,33 +1,34 @@
1
1
  import { createRequire } from 'node:module'
2
2
  import { writeFile } from 'node:fs/promises'
3
3
  import { basename, extname, join } from 'node:path'
4
- import { deckLocale } from '@lincyaw/kernel'
4
+ import { deckLocale, WEB_RENDERER_PACKAGE } from '@lincyaw/kernel'
5
5
  import type { IRDocument, IRSection, PresmiumNodePlugin, RenderContext } from '@lincyaw/kernel'
6
- import { DEFAULT_KERNEL, renderNotebook, support } from '../domain/notebook.ts'
7
- import type { KernelSpec, NotebookOptions } from '../domain/notebook.ts'
6
+ import { DEFAULT_JUPYTER_KERNEL, notebookSupport, renderNotebook } from '../domain/notebook.ts'
7
+ import type { JupyterKernelSpec, NotebookOptions } from '../domain/notebook.ts'
8
8
  import { localMedia } from './media.ts'
9
9
 
10
10
  export type { NotebookCellInput } from '../domain/notebook.ts'
11
11
 
12
- const CHECK_ID = 'presmium.notebook'
12
+ /** The notebook renderer's plugin ID, which is also the ID of the checks it reports. */
13
+ const PLUGIN_ID = 'presmium.notebook'
13
14
  const { version } = createRequire(import.meta.url)('../../package.json') as { version: string }
14
15
 
15
16
  const plugin: PresmiumNodePlugin = {
16
- id: CHECK_ID,
17
+ id: PLUGIN_ID,
17
18
  version,
18
19
  kernel: '^1.0.0',
19
20
  renderer: {
20
21
  id: 'notebook',
21
- support,
22
+ support: notebookSupport,
22
23
  async build(doc, ctx) {
23
- const { notebook, skipped, problems, degraded, unlinked } = renderNotebook(doc, { ...await options(doc, ctx), media: localMedia(ctx) })
24
- for (const { message, source } of problems) ctx.report({ checkId: CHECK_ID, severity: 'warning', message, source: { file: source.file, line: source.line } })
24
+ const { notebook, skipped, problems, degraded, unlinked } = renderNotebook(doc, { ...await notebookOptions(doc, ctx), media: localMedia(ctx) })
25
+ for (const { message, source } of problems) ctx.report({ checkId: PLUGIN_ID, severity: 'warning', message, source: { file: source.file, line: source.line } })
25
26
  for (const { section, block, nodes, source } of degraded) {
26
27
  const message = `${section}#${block}: degraded in the notebook, which has no Jupyter form for ${nodes.join(', ')}; the text is kept`
27
- ctx.report({ checkId: CHECK_ID, severity: 'warning', message, source: { file: source.file, line: source.line } })
28
+ ctx.report({ checkId: PLUGIN_ID, severity: 'warning', message, source: { file: source.file, line: source.line } })
28
29
  }
29
30
  if (unlinked > 0) {
30
- ctx.report({ checkId: CHECK_ID, severity: 'info', message: `${unlinked} components the notebook cannot show are noted without a link to the web version; set renderers.notebook.webUrl to link them` })
31
+ ctx.report({ checkId: PLUGIN_ID, severity: 'info', message: `${unlinked} components the notebook cannot show are noted without a link to the web version; set renderers.notebook.webUrl to link them` })
31
32
  }
32
33
  const name = basename(doc.meta.entry, extname(doc.meta.entry)) || 'notebook'
33
34
  await writeFile(join(ctx.outDir, `${name}.ipynb`), `${JSON.stringify(notebook, null, 1)}\n`)
@@ -38,32 +39,31 @@ const plugin: PresmiumNodePlugin = {
38
39
 
39
40
  export default plugin
40
41
 
41
- async function options(doc: IRDocument, ctx: RenderContext): Promise<Omit<NotebookOptions, 'media'>> {
42
- const warn = (message: string) => ctx.report({ checkId: CHECK_ID, severity: 'warning', message })
42
+ async function notebookOptions(doc: IRDocument, ctx: RenderContext): Promise<Omit<NotebookOptions, 'media'>> {
43
+ const warn = (message: string) => ctx.report({ checkId: PLUGIN_ID, severity: 'warning', message })
43
44
  const { kernel, webUrl } = ctx.config
44
45
  if (webUrl !== undefined && typeof webUrl !== 'string') warn('renderers.notebook.webUrl must be a URL; components are not linked to the web version')
45
- return { kernel: kernelSpec(kernel, warn), locale: deckLocale(ctx.lang), webLink: await webLink(doc, ctx, webUrl), extensionFor: ctx.extensionFor }
46
+ return { jupyterKernel: jupyterKernelSpec(kernel, warn), locale: deckLocale(ctx.lang), webLink: await webLink(doc, ctx, webUrl), extensionFor: ctx.extensionFor }
46
47
  }
47
48
 
48
- const WEB_PACKAGE = '@lincyaw/web'
49
-
50
49
  async function webLink(doc: IRDocument, ctx: RenderContext, webUrl: unknown): Promise<((section: IRSection) => string) | undefined> {
51
50
  if (typeof webUrl === 'string') return section => `${webUrl.replace(/#.*$/, '')}#${encodeURIComponent(section.id)}`
52
- const web = doc.config.renderers.web?.package ?? WEB_PACKAGE
53
- if (!ctx.renderers.includes('web') || web !== WEB_PACKAGE) return undefined
54
- const layout = await import('@lincyaw/web/domain').catch(() => undefined)
55
- if (!layout) return undefined
56
- const links = layout.sectionLinks(layout.toPages(doc))
51
+ const web = doc.config.renderers.web?.package ?? WEB_RENDERER_PACKAGE
52
+ if (!ctx.renderers.includes('web') || web !== WEB_RENDERER_PACKAGE) return undefined
53
+ const webDomain = await import('@lincyaw/web/domain').catch(() => undefined)
54
+ if (!webDomain) return undefined
55
+ const links = webDomain.sectionLinks(webDomain.webPages(doc))
57
56
  return section => `../web${(links[section.id] ?? '/').replace(/^\/(?=#|$)/, '/index.html')}`
58
57
  }
59
58
 
60
- function kernelSpec(value: unknown, warn: (message: string) => void): KernelSpec {
61
- if (value === undefined || value === DEFAULT_KERNEL.name) return DEFAULT_KERNEL
59
+ /** The Jupyter kernel the configuration `renderers.notebook.kernel` names: a name, or `{ name, language, display_name }`. */
60
+ function jupyterKernelSpec(value: unknown, warn: (message: string) => void): JupyterKernelSpec {
61
+ if (value === undefined || value === DEFAULT_JUPYTER_KERNEL.name) return DEFAULT_JUPYTER_KERNEL
62
62
  if (typeof value === 'string' && value) return { name: value, display_name: value, language: value.replace(/[-\d.]+$/, '') || value }
63
63
  if (typeof value === 'object' && value !== null && 'name' in value && 'language' in value && typeof value.name === 'string' && typeof value.language === 'string') {
64
64
  const displayName = 'display_name' in value && typeof value.display_name === 'string' ? value.display_name : value.name
65
65
  return { name: value.name, display_name: displayName, language: value.language }
66
66
  }
67
- warn(`renderers.notebook.kernel must be a kernel name or { name, language, display_name }; using ${DEFAULT_KERNEL.name}`)
68
- return DEFAULT_KERNEL
67
+ warn(`renderers.notebook.kernel must be a kernel name or { name, language, display_name }; using ${DEFAULT_JUPYTER_KERNEL.name}`)
68
+ return DEFAULT_JUPYTER_KERNEL
69
69
  }
package/src/node/.gitkeep DELETED
File without changes