@erclx/canon 4.75.1 → 4.77.0

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.
@@ -0,0 +1,113 @@
1
+ import { escape, isRaw, raw, type RawMarkup } from './raw'
2
+
3
+ export type Child =
4
+ | string
5
+ | number
6
+ | boolean
7
+ | null
8
+ | undefined
9
+ | RawMarkup
10
+ | readonly Child[]
11
+
12
+ export interface Props {
13
+ readonly children?: Child
14
+ readonly [key: string]: unknown
15
+ }
16
+
17
+ /**
18
+ * What a component returns and what `jsx` produces: markup already rendered,
19
+ * carrying the same brand `raw` gives an author's own escape hatch. One brand
20
+ * for both is what lets a parent's `{children}` compose a child's output
21
+ * without escaping it a second time, since a plain string cannot say whether
22
+ * it is author text or already-rendered markup.
23
+ */
24
+ export type Element = RawMarkup
25
+
26
+ export type Component<P extends Props = Props> = (props: P) => Element
27
+
28
+ export const Fragment = Symbol('Fragment')
29
+
30
+ const ARIA_PREFIX = 'aria-'
31
+
32
+ const VOID_ELEMENTS = new Set([
33
+ 'area',
34
+ 'base',
35
+ 'br',
36
+ 'col',
37
+ 'embed',
38
+ 'hr',
39
+ 'img',
40
+ 'input',
41
+ 'link',
42
+ 'meta',
43
+ 'source',
44
+ 'track',
45
+ 'wbr',
46
+ ])
47
+
48
+ function renderChild(child: Child): string {
49
+ if (child === null || child === undefined || typeof child === 'boolean') {
50
+ return ''
51
+ }
52
+ if (typeof child === 'number') return String(child)
53
+ if (typeof child === 'string') return escape(child)
54
+ if (isRaw(child)) return child.html
55
+ return child.map(renderChild).join('')
56
+ }
57
+
58
+ /**
59
+ * ARIA takes the literal word for a boolean, where HTML boolean attributes
60
+ * take presence or absence. Spike 1's first draft rendered a bare
61
+ * `aria-disabled` for `true` and dropped `aria-disabled={false}` entirely,
62
+ * silently removing state a screen reader reads.
63
+ */
64
+ function renderAttribute(key: string, value: unknown): string {
65
+ if (key.startsWith(ARIA_PREFIX) && typeof value === 'boolean') {
66
+ return ` ${key}="${value}"`
67
+ }
68
+ if (value === undefined || value === null || value === false) return ''
69
+ if (value === true) return ` ${key}`
70
+ if (isRaw(value)) return ` ${key}="${escape(value.html)}"`
71
+ return ` ${key}="${escape(String(value))}"`
72
+ }
73
+
74
+ function renderAttributes(props: Props): string {
75
+ return Object.entries(props)
76
+ .filter(([key]) => key !== 'children')
77
+ .map(([key, value]) => renderAttribute(key, value))
78
+ .join('')
79
+ }
80
+
81
+ export function jsx(
82
+ type: string | Component | typeof Fragment,
83
+ props: Props,
84
+ ): Element {
85
+ if (type === Fragment) return raw(renderChild(props.children ?? null))
86
+ if (typeof type === 'function') return type(props)
87
+
88
+ const attrs = renderAttributes(props)
89
+ if (VOID_ELEMENTS.has(type)) return raw(`<${type}${attrs}>`)
90
+
91
+ return raw(
92
+ `<${type}${attrs}>${renderChild(props.children ?? null)}</${type}>`,
93
+ )
94
+ }
95
+
96
+ export const jsxs = jsx
97
+
98
+ /** Unwraps a rendered element to the plain string a file writes to disk. */
99
+ export function render(element: Element): string {
100
+ return element.html
101
+ }
102
+
103
+ export namespace JSX {
104
+ export type Element = RawMarkup
105
+
106
+ export interface ElementChildrenAttribute {
107
+ children: Record<string, never>
108
+ }
109
+
110
+ export interface IntrinsicElements {
111
+ [name: string]: Props
112
+ }
113
+ }
@@ -0,0 +1,28 @@
1
+ const RAW = Symbol('raw')
2
+
3
+ /**
4
+ * Markup already rendered, exempt from the escaping every other string
5
+ * receives. `raw` is the only way to produce one, so a lesson has to opt in
6
+ * explicitly rather than an author text string accidentally passing through
7
+ * unescaped.
8
+ */
9
+ export interface RawMarkup {
10
+ readonly [RAW]: true
11
+ readonly html: string
12
+ }
13
+
14
+ export function raw(html: string): RawMarkup {
15
+ return { [RAW]: true, html }
16
+ }
17
+
18
+ export function isRaw(value: unknown): value is RawMarkup {
19
+ return typeof value === 'object' && value !== null && RAW in value
20
+ }
21
+
22
+ export function escape(value: string): string {
23
+ return value
24
+ .replace(/&/g, '&amp;')
25
+ .replace(/</g, '&lt;')
26
+ .replace(/>/g, '&gt;')
27
+ .replace(/"/g, '&quot;')
28
+ }
@@ -0,0 +1,95 @@
1
+ /** @jsxImportSource ./html */
2
+ import { mkdir, writeFile } from 'node:fs/promises'
3
+ import { join } from 'node:path'
4
+ import { TEACH_STYLESHEET_COMPONENTS } from '@/design/components'
5
+ import { buildDesignCss } from '@/design/css'
6
+ import { TEACH_FONT_FACES } from '@/teach/fonts'
7
+ import { Heading } from '@/teach/components/heading'
8
+ import { List } from '@/teach/components/list'
9
+ import { Paragraph } from '@/teach/components/paragraph'
10
+ import { render } from '@/teach/html/jsx-runtime'
11
+
12
+ /**
13
+ * Regenerates the committed fixture lesson under
14
+ * `examples/teach/00-fixture/`, the same shape `examples/slides/showcase.md`
15
+ * takes against its own hand-rendered snapshot. Run with
16
+ * `bun src/teach/render-fixture.tsx` after a component changes shape.
17
+ */
18
+
19
+ const FIXTURE_ROOT = join(
20
+ import.meta.dir,
21
+ '..',
22
+ '..',
23
+ 'examples',
24
+ 'teach',
25
+ '00-fixture',
26
+ )
27
+ const LESSON_TITLE = 'Compass bearings'
28
+ const LESSON_FILE = '0001-compass-bearings.html'
29
+
30
+ const STEPS = [
31
+ 'Point the direction-of-travel arrow at the landmark.',
32
+ 'Rotate the bezel until the orienting arrow lines up with the needle.',
33
+ 'Read the bearing where the direction-of-travel arrow meets the bezel.',
34
+ ]
35
+
36
+ const body = (
37
+ <>
38
+ <Heading level={1}>{LESSON_TITLE}</Heading>
39
+ <Paragraph lede>
40
+ A bearing is the compass direction from where you stand to whatever you
41
+ are aiming at, measured clockwise from north.
42
+ </Paragraph>
43
+ <Paragraph>
44
+ Hold the compass level and let the needle settle before reading anything
45
+ off it. A bearing taken while walking or tilted reads confidently and
46
+ wrong.
47
+ </Paragraph>
48
+ <Paragraph>
49
+ Three steps turn a sighted landmark into a number you can act on:
50
+ </Paragraph>
51
+ <List ordered items={STEPS} />
52
+ <Paragraph>
53
+ The same three steps run in reverse turn a bearing on a map into a
54
+ direction to walk, which is the only reason to learn them in this order.
55
+ </Paragraph>
56
+ </>
57
+ )
58
+
59
+ function page(title: string, main: string): string {
60
+ return `<!doctype html>
61
+ <html lang="en">
62
+ <head>
63
+ <meta charset="utf-8">
64
+ <meta name="viewport" content="width=device-width, initial-scale=1">
65
+ <title>${title}</title>
66
+ <link rel="stylesheet" href="../assets/course.css">
67
+ </head>
68
+ <body>
69
+ <main class="wide-body">
70
+ ${main}
71
+ </main>
72
+ </body>
73
+ </html>
74
+ `
75
+ }
76
+
77
+ async function main(): Promise<void> {
78
+ await mkdir(join(FIXTURE_ROOT, 'lessons'), { recursive: true })
79
+ await mkdir(join(FIXTURE_ROOT, 'assets'), { recursive: true })
80
+
81
+ await writeFile(
82
+ join(FIXTURE_ROOT, 'assets', 'course.css'),
83
+ buildDesignCss(undefined, {
84
+ embedFonts: TEACH_FONT_FACES,
85
+ components: TEACH_STYLESHEET_COMPONENTS,
86
+ }),
87
+ )
88
+
89
+ await writeFile(
90
+ join(FIXTURE_ROOT, 'lessons', LESSON_FILE),
91
+ page(LESSON_TITLE, render(body)),
92
+ )
93
+ }
94
+
95
+ await main()
package/tsconfig.json CHANGED
@@ -3,6 +3,7 @@
3
3
  "target": "ESNext",
4
4
  "module": "ESNext",
5
5
  "moduleResolution": "bundler",
6
+ "jsx": "react-jsx",
6
7
  "types": ["bun-types"],
7
8
  "strict": true,
8
9
  "skipLibCheck": true,