orga-build 0.8.0 → 0.10.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.
- package/README.org +296 -41
- package/cli.js +4 -4
- package/lib/__tests__/build.test.js +444 -23
- package/lib/__tests__/dev.test.d.ts +2 -0
- package/lib/__tests__/dev.test.d.ts.map +1 -0
- package/lib/__tests__/dev.test.js +295 -0
- package/lib/__tests__/fixtures.d.ts +8 -0
- package/lib/__tests__/fixtures.d.ts.map +1 -0
- package/lib/__tests__/fixtures.js +61 -0
- package/lib/app.jsx +23 -50
- package/lib/build.d.ts +4 -1
- package/lib/build.d.ts.map +1 -1
- package/lib/build.js +12 -176
- package/lib/config.d.ts +9 -3
- package/lib/config.d.ts.map +1 -1
- package/lib/config.js +14 -18
- package/lib/content.d.ts +6 -0
- package/lib/dev-ssr.d.ts +14 -0
- package/lib/dev-ssr.d.ts.map +1 -0
- package/lib/dev-ssr.js +137 -0
- package/lib/endpoint.d.ts +5 -0
- package/lib/endpoint.d.ts.map +1 -1
- package/lib/endpoint.js +1 -0
- package/lib/files.d.ts +10 -3
- package/lib/files.d.ts.map +1 -1
- package/lib/files.js +111 -23
- package/lib/fs.d.ts +0 -5
- package/lib/fs.d.ts.map +1 -1
- package/lib/fs.js +0 -18
- package/lib/html.d.ts +37 -0
- package/lib/html.d.ts.map +1 -0
- package/lib/html.js +101 -0
- package/lib/index.html +0 -1
- package/lib/island-client.d.ts +5 -0
- package/lib/island-client.d.ts.map +1 -0
- package/lib/island-client.js +41 -0
- package/lib/island.d.ts +18 -0
- package/lib/island.d.ts.map +1 -0
- package/lib/island.js +146 -0
- package/lib/island.jsx +108 -0
- package/lib/orga.d.ts +1 -1
- package/lib/orga.d.ts.map +1 -1
- package/lib/orga.js +54 -16
- package/lib/plugin.d.ts +25 -32
- package/lib/plugin.d.ts.map +1 -1
- package/lib/plugin.js +130 -170
- package/lib/prerender.d.ts +11 -0
- package/lib/prerender.d.ts.map +1 -0
- package/lib/prerender.js +184 -0
- package/lib/serve.d.ts.map +1 -1
- package/lib/serve.js +5 -23
- package/lib/ssr.jsx +9 -14
- package/lib/util.d.ts +0 -22
- package/lib/util.d.ts.map +1 -1
- package/lib/util.js +0 -62
- package/lib/vite.d.ts +7 -6
- package/lib/vite.d.ts.map +1 -1
- package/lib/vite.js +75 -23
- package/package.json +5 -8
- package/lib/components.d.ts +0 -2
- package/lib/components.d.ts.map +0 -1
- package/lib/components.js +0 -1
- package/lib/csr.jsx +0 -11
- package/lib/watch.d.ts +0 -10
- package/lib/watch.d.ts.map +0 -1
- package/lib/watch.js +0 -53
package/lib/html.js
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import fs from 'node:fs/promises'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import { fileURLToPath } from 'node:url'
|
|
4
|
+
import { exists } from './fs.js'
|
|
5
|
+
import { escapeHtml } from './util.js'
|
|
6
|
+
|
|
7
|
+
const defaultIndexHtml = fileURLToPath(new URL('./index.html', import.meta.url))
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Read the HTML shell: the user's `index.html` in the Vite root, or the
|
|
11
|
+
* default one shipped with orga-build.
|
|
12
|
+
*
|
|
13
|
+
* @param {string} root - Vite root
|
|
14
|
+
*/
|
|
15
|
+
export async function readIndexHtml(root) {
|
|
16
|
+
const userIndexHtml = path.join(root, 'index.html')
|
|
17
|
+
const file = (await exists(userIndexHtml)) ? userIndexHtml : defaultIndexHtml
|
|
18
|
+
return fs.readFile(file, 'utf-8')
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Makes `<root>/index.html` always loadable, falling back to the default shell,
|
|
23
|
+
* so Vite can use it as the client build entry. Vite then bundles its scripts
|
|
24
|
+
* and stylesheets and injects the hashed asset tags itself.
|
|
25
|
+
*
|
|
26
|
+
* Global styles are added as `<link>` tags, which Vite serves (with HMR) in
|
|
27
|
+
* dev and bundles in build.
|
|
28
|
+
*
|
|
29
|
+
* @param {string[]} [styles]
|
|
30
|
+
* @returns {import('vite').Plugin}
|
|
31
|
+
*/
|
|
32
|
+
export function htmlShellPlugin(styles = []) {
|
|
33
|
+
/** @type {string} */
|
|
34
|
+
let root
|
|
35
|
+
/** @type {string} */
|
|
36
|
+
let indexHtmlPath
|
|
37
|
+
|
|
38
|
+
return {
|
|
39
|
+
name: 'orga-build:html-shell',
|
|
40
|
+
enforce: 'pre',
|
|
41
|
+
configResolved(config) {
|
|
42
|
+
root = config.root
|
|
43
|
+
indexHtmlPath = path.join(root, 'index.html')
|
|
44
|
+
},
|
|
45
|
+
resolveId(id, importer) {
|
|
46
|
+
// Build entries arrive relative to the root, without an importer.
|
|
47
|
+
if (!importer && path.resolve(root, id) === indexHtmlPath) {
|
|
48
|
+
return indexHtmlPath
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
async load(id) {
|
|
52
|
+
if (id === indexHtmlPath) {
|
|
53
|
+
return readIndexHtml(root)
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
transformIndexHtml: {
|
|
57
|
+
order: 'pre',
|
|
58
|
+
handler() {
|
|
59
|
+
return [...new Set(styles)].map((href) => ({
|
|
60
|
+
tag: 'link',
|
|
61
|
+
attrs: { rel: 'stylesheet', href },
|
|
62
|
+
injectTo: 'head'
|
|
63
|
+
}))
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Fill a processed HTML shell with a server-rendered page. The island runtime
|
|
71
|
+
* is only linked when the page has an island, so other pages ship no script.
|
|
72
|
+
*
|
|
73
|
+
* @param {string} template - HTML shell, already transformed by Vite
|
|
74
|
+
* @param {Object} options
|
|
75
|
+
* @param {string | undefined} options.content - Rendered page markup
|
|
76
|
+
* @param {Record<string, unknown> | undefined} options.page - Page module exports, used for `%orga.*%` placeholders
|
|
77
|
+
* @param {string | undefined} [options.islandScript] - URL of the island runtime
|
|
78
|
+
* @param {string[]} [options.styles] - URLs of stylesheets imported by server-rendered code
|
|
79
|
+
*/
|
|
80
|
+
export function renderPageHtml(
|
|
81
|
+
template,
|
|
82
|
+
{ content, page, islandScript, styles = [] }
|
|
83
|
+
) {
|
|
84
|
+
let html = template
|
|
85
|
+
// Unknown routes render nothing (`undefined`); a page may render ''.
|
|
86
|
+
if (content !== undefined) {
|
|
87
|
+
html = html.replace(
|
|
88
|
+
'<div id="root"></div>',
|
|
89
|
+
`<div id="root">${content}</div>`
|
|
90
|
+
)
|
|
91
|
+
const head = styles.map((href) => `<link rel="stylesheet" href="${href}">`)
|
|
92
|
+
if (islandScript && content.includes('<orga-island')) {
|
|
93
|
+
head.push(`<script type="module" src="${islandScript}"></script>`)
|
|
94
|
+
}
|
|
95
|
+
if (head.length) html = html.replace(/<\/head>/i, `${head.join('')}$&`)
|
|
96
|
+
}
|
|
97
|
+
// Unknown routes have no page: their placeholders resolve to empty strings.
|
|
98
|
+
return html.replace(/%orga\.(\w+)%/g, (_, key) =>
|
|
99
|
+
escapeHtml(String(page?.[key] ?? ''))
|
|
100
|
+
)
|
|
101
|
+
}
|
package/lib/index.html
CHANGED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"island-client.d.ts","sourceRoot":"","sources":["island-client.js"],"names":[],"mappings":"AAqBA;;GAEG;AACH,6BAFW,OAAO,iBAkBjB"}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hydrates every `<orga-island>` on the page: imports the module the server
|
|
3
|
+
* referenced and mounts React on the server-rendered markup. React itself is
|
|
4
|
+
* only loaded once an island hydrates, so `client="visible"` islands below the
|
|
5
|
+
* fold cost nothing until scrolled into view.
|
|
6
|
+
*/
|
|
7
|
+
for (const el of Array.from(document.querySelectorAll('orga-island'))) {
|
|
8
|
+
// The island has `display: contents` and no box of its own, so visibility
|
|
9
|
+
// is watched on its children; a text-only island has none and hydrates now.
|
|
10
|
+
if (el.getAttribute('client') !== 'visible' || !el.children.length) {
|
|
11
|
+
hydrate(el)
|
|
12
|
+
continue
|
|
13
|
+
}
|
|
14
|
+
const observer = new IntersectionObserver((entries) => {
|
|
15
|
+
if (!entries.some((entry) => entry.isIntersecting)) return
|
|
16
|
+
observer.disconnect()
|
|
17
|
+
hydrate(el)
|
|
18
|
+
})
|
|
19
|
+
for (const child of Array.from(el.children)) observer.observe(child)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* @param {Element} el
|
|
24
|
+
*/
|
|
25
|
+
async function hydrate(el) {
|
|
26
|
+
const [{ createElement }, { hydrateRoot }, mod] = await Promise.all([
|
|
27
|
+
import('react'),
|
|
28
|
+
import('react-dom/client'),
|
|
29
|
+
import(
|
|
30
|
+
/* @vite-ignore */
|
|
31
|
+
new URL(/** @type {string} */ (el.getAttribute('src')), document.baseURI)
|
|
32
|
+
.href
|
|
33
|
+
)
|
|
34
|
+
])
|
|
35
|
+
const Component = mod[/** @type {string} */ (el.getAttribute('export'))]
|
|
36
|
+
const props = JSON.parse(el.getAttribute('props') || '{}')
|
|
37
|
+
hydrateRoot(el, createElement(Component, props), {
|
|
38
|
+
// Same prefix the server rendered this island with, so `useId` matches.
|
|
39
|
+
identifierPrefix: el.getAttribute('prefix') || ''
|
|
40
|
+
})
|
|
41
|
+
}
|
package/lib/island.d.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Islands: a module that starts with React's `'use client'` directive runs in
|
|
3
|
+
* the browser. In the `ssr` environment each component it exports (named in
|
|
4
|
+
* PascalCase, or the default export) is wrapped so it renders inside an
|
|
5
|
+
* `<orga-island>` element (see `island.jsx`); hooks and other exports pass
|
|
6
|
+
* through. In the browser the client runtime imports the real module and
|
|
7
|
+
* hydrates it there. Pages without islands ship no JavaScript.
|
|
8
|
+
*
|
|
9
|
+
* Islands are discovered while the `ssr` environment is transformed, so the
|
|
10
|
+
* `client` environment must be built after it: its `buildStart` emits a chunk
|
|
11
|
+
* per island plus the runtime, and the client manifest maps them to hashed
|
|
12
|
+
* files for prerendering.
|
|
13
|
+
*
|
|
14
|
+
* @returns {import('vite').Plugin}
|
|
15
|
+
*/
|
|
16
|
+
export function islandPlugin(): import("vite").Plugin;
|
|
17
|
+
export const clientRuntime: string;
|
|
18
|
+
//# sourceMappingURL=island.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"island.d.ts","sourceRoot":"","sources":["island.js"],"names":[],"mappings":"AAWA;;;;;;;;;;;;;;GAcG;AACH,gCAFa,OAAO,MAAM,EAAE,MAAM,CAoFjC;AAvGD,mCAEC"}
|
package/lib/island.js
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import path from 'node:path'
|
|
2
|
+
import { fileURLToPath } from 'node:url'
|
|
3
|
+
import { normalizePath } from 'vite'
|
|
4
|
+
|
|
5
|
+
const serverEntry = fileURLToPath(new URL('./island.jsx', import.meta.url))
|
|
6
|
+
export const clientRuntime = fileURLToPath(
|
|
7
|
+
new URL('./island-client.js', import.meta.url)
|
|
8
|
+
)
|
|
9
|
+
const implQuery = '?orga-island'
|
|
10
|
+
const directive = /^(?:\s|\/\/.*|\/\*[\s\S]*?\*\/)*(['"])use client\1/
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Islands: a module that starts with React's `'use client'` directive runs in
|
|
14
|
+
* the browser. In the `ssr` environment each component it exports (named in
|
|
15
|
+
* PascalCase, or the default export) is wrapped so it renders inside an
|
|
16
|
+
* `<orga-island>` element (see `island.jsx`); hooks and other exports pass
|
|
17
|
+
* through. In the browser the client runtime imports the real module and
|
|
18
|
+
* hydrates it there. Pages without islands ship no JavaScript.
|
|
19
|
+
*
|
|
20
|
+
* Islands are discovered while the `ssr` environment is transformed, so the
|
|
21
|
+
* `client` environment must be built after it: its `buildStart` emits a chunk
|
|
22
|
+
* per island plus the runtime, and the client manifest maps them to hashed
|
|
23
|
+
* files for prerendering.
|
|
24
|
+
*
|
|
25
|
+
* @returns {import('vite').Plugin}
|
|
26
|
+
*/
|
|
27
|
+
export function islandPlugin() {
|
|
28
|
+
/** @type {Set<string>} */
|
|
29
|
+
const islands = new Set()
|
|
30
|
+
/** @type {string} */
|
|
31
|
+
let root
|
|
32
|
+
let ssrBuilt = false
|
|
33
|
+
let clientBuiltAfterSsr = false
|
|
34
|
+
/** @type {Set<string>} */
|
|
35
|
+
const ssrAssets = new Set()
|
|
36
|
+
|
|
37
|
+
return {
|
|
38
|
+
name: 'orga-build:islands',
|
|
39
|
+
|
|
40
|
+
api: {
|
|
41
|
+
/** Whether the client build predates island discovery and lacks them. */
|
|
42
|
+
get clientIsStale() {
|
|
43
|
+
return islands.size > 0 && !clientBuiltAfterSsr
|
|
44
|
+
},
|
|
45
|
+
/** Files (relative to the SSR outDir) the SSR build emitted as assets. */
|
|
46
|
+
ssrAssets
|
|
47
|
+
},
|
|
48
|
+
|
|
49
|
+
configResolved(config) {
|
|
50
|
+
root = config.root
|
|
51
|
+
},
|
|
52
|
+
|
|
53
|
+
transform: {
|
|
54
|
+
filter: { code: 'use client' },
|
|
55
|
+
handler(code, id) {
|
|
56
|
+
if (this.environment.name !== 'ssr' || id.includes(implQuery)) return
|
|
57
|
+
if (!directive.test(code)) return
|
|
58
|
+
const src = normalizePath(path.relative(root, id))
|
|
59
|
+
const ast = this.parse(code)
|
|
60
|
+
if (ast.body.some((node) => node.type === 'ExportAllDeclaration')) {
|
|
61
|
+
this.error(`'use client' modules can't use \`export *\``)
|
|
62
|
+
}
|
|
63
|
+
const names = exportNames(ast)
|
|
64
|
+
if (names.some(isComponentName)) islands.add(id)
|
|
65
|
+
const exports = names.map((name) => {
|
|
66
|
+
const binding =
|
|
67
|
+
name === 'default' ? 'export default' : `export const ${name} =`
|
|
68
|
+
const key = JSON.stringify(name)
|
|
69
|
+
return isComponentName(name)
|
|
70
|
+
? `${binding} island(impl[${key}], ${JSON.stringify(src)}, ${key})`
|
|
71
|
+
: `${binding} impl[${key}]`
|
|
72
|
+
})
|
|
73
|
+
return [
|
|
74
|
+
`import * as impl from ${JSON.stringify(id + implQuery)}`,
|
|
75
|
+
`import { island } from ${JSON.stringify(serverEntry)}`,
|
|
76
|
+
...exports
|
|
77
|
+
].join('\n')
|
|
78
|
+
}
|
|
79
|
+
},
|
|
80
|
+
|
|
81
|
+
buildEnd() {
|
|
82
|
+
if (this.environment.name === 'ssr') ssrBuilt = true
|
|
83
|
+
},
|
|
84
|
+
|
|
85
|
+
// Pages live only in the SSR graph, so their images and CSS are emitted
|
|
86
|
+
// there (see `emitAssets`); remember which files, for prerendering to
|
|
87
|
+
// copy into the site. Source maps stay behind. `writeBundle` sees the
|
|
88
|
+
// final bundle, after Vite's CSS plugin has added its file.
|
|
89
|
+
writeBundle(_options, bundle) {
|
|
90
|
+
if (this.environment.name !== 'ssr') return
|
|
91
|
+
ssrAssets.clear()
|
|
92
|
+
for (const output of Object.values(bundle)) {
|
|
93
|
+
if (output.type === 'asset' && !output.fileName.endsWith('.map')) {
|
|
94
|
+
ssrAssets.add(output.fileName)
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
},
|
|
98
|
+
|
|
99
|
+
buildStart() {
|
|
100
|
+
if (this.environment.name !== 'client') return
|
|
101
|
+
clientBuiltAfterSsr = ssrBuilt
|
|
102
|
+
if (this.environment.mode !== 'build' || !islands.size) return
|
|
103
|
+
for (const id of [...islands, clientRuntime]) {
|
|
104
|
+
// Vite drops entry exports in app builds; the runtime needs them.
|
|
105
|
+
this.emitFile({ type: 'chunk', id, preserveSignature: 'strict' })
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Components are PascalCase by convention, as React Fast Refresh assumes.
|
|
113
|
+
*
|
|
114
|
+
* @param {string} name
|
|
115
|
+
*/
|
|
116
|
+
function isComponentName(name) {
|
|
117
|
+
return name === 'default' || /^[A-Z]/.test(name)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Names of a module's exports. Destructured exports are skipped.
|
|
122
|
+
*
|
|
123
|
+
* @param {ReturnType<import('vite').Rollup.PluginContext['parse']>} ast
|
|
124
|
+
*/
|
|
125
|
+
function exportNames(ast) {
|
|
126
|
+
/** @type {string[]} */
|
|
127
|
+
const names = []
|
|
128
|
+
for (const node of ast.body) {
|
|
129
|
+
if (node.type === 'ExportDefaultDeclaration') names.push('default')
|
|
130
|
+
if (node.type !== 'ExportNamedDeclaration') continue
|
|
131
|
+
const declaration = node.declaration
|
|
132
|
+
if (declaration && 'declarations' in declaration) {
|
|
133
|
+
for (const { id } of declaration.declarations) {
|
|
134
|
+
if (id.type === 'Identifier') names.push(id.name)
|
|
135
|
+
}
|
|
136
|
+
} else if (declaration?.id && 'name' in declaration.id) {
|
|
137
|
+
names.push(declaration.id.name)
|
|
138
|
+
}
|
|
139
|
+
for (const { exported } of node.specifiers) {
|
|
140
|
+
names.push(
|
|
141
|
+
exported.type === 'Identifier' ? exported.name : exported.value
|
|
142
|
+
)
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return names
|
|
146
|
+
}
|
package/lib/island.jsx
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { createContext, useContext } from 'react'
|
|
2
|
+
import { renderToString } from 'react-dom/server'
|
|
3
|
+
|
|
4
|
+
/** Set while rendering inside an island: nested islands render inline. */
|
|
5
|
+
const InsideIsland = createContext(false)
|
|
6
|
+
|
|
7
|
+
/** @type {(src: string) => string} */
|
|
8
|
+
let resolveUrl = (src) => src
|
|
9
|
+
let islandCount = 0
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Start rendering a page: set how an island's source path (relative to the
|
|
13
|
+
* Vite root) becomes the URL the browser imports it from, and restart the
|
|
14
|
+
* per-page island numbering.
|
|
15
|
+
*
|
|
16
|
+
* @param {(src: string) => string} fn
|
|
17
|
+
*/
|
|
18
|
+
export function beginPage(fn) {
|
|
19
|
+
resolveUrl = fn
|
|
20
|
+
islandCount = 0
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Server-side wrapper for a `'use client'` export. Components render inside an
|
|
25
|
+
* `<orga-island>` that tells the client runtime what to import and hydrate;
|
|
26
|
+
* other exports pass through unchanged.
|
|
27
|
+
*
|
|
28
|
+
* Each island is rendered as its own React tree with its own `identifierPrefix`,
|
|
29
|
+
* matching how the browser hydrates it, so `useId` values line up.
|
|
30
|
+
*
|
|
31
|
+
* Props are serialized as JSON, so functions and `children` can't cross to the
|
|
32
|
+
* browser. The `client` prop picks when to hydrate: `load` (default) or
|
|
33
|
+
* `visible`.
|
|
34
|
+
*
|
|
35
|
+
* @template T
|
|
36
|
+
* @param {T} Component
|
|
37
|
+
* @param {string} src - Source path relative to the Vite root
|
|
38
|
+
* @param {string} name - Export name
|
|
39
|
+
* @returns {T}
|
|
40
|
+
*/
|
|
41
|
+
export function island(Component, src, name) {
|
|
42
|
+
if (isType(Component, 'react.lazy')) {
|
|
43
|
+
throw new Error(
|
|
44
|
+
`<${name}> from ${src}: a lazy() component can't be an island because it can't be prerendered, use lazy() inside the island instead`
|
|
45
|
+
)
|
|
46
|
+
}
|
|
47
|
+
if (!isComponent(Component)) return Component
|
|
48
|
+
const Island = ({ client = 'load', ...props }) => {
|
|
49
|
+
// An island inside another island is already client code.
|
|
50
|
+
if (useContext(InsideIsland)) return <Component {...props} />
|
|
51
|
+
/** @param {string} key */
|
|
52
|
+
const reject = (key) => {
|
|
53
|
+
throw new Error(
|
|
54
|
+
`<${name}> from ${src} is an island: prop "${key}" can't be sent to the browser, pass JSON-serializable props only`
|
|
55
|
+
)
|
|
56
|
+
}
|
|
57
|
+
if ('children' in props) reject('children')
|
|
58
|
+
const json = JSON.stringify(props, (key, value) =>
|
|
59
|
+
typeof value === 'function' ? reject(key) : value
|
|
60
|
+
)
|
|
61
|
+
const prefix = `island-${islandCount++}-`
|
|
62
|
+
// Render from the serialized props: the browser sees exactly these.
|
|
63
|
+
const html = renderToString(
|
|
64
|
+
<InsideIsland value={true}>
|
|
65
|
+
<Component {...JSON.parse(json)} />
|
|
66
|
+
</InsideIsland>,
|
|
67
|
+
{ identifierPrefix: prefix }
|
|
68
|
+
)
|
|
69
|
+
return (
|
|
70
|
+
<orga-island
|
|
71
|
+
src={resolveUrl(src)}
|
|
72
|
+
export={name}
|
|
73
|
+
props={json}
|
|
74
|
+
prefix={prefix}
|
|
75
|
+
client={client}
|
|
76
|
+
style={{ display: 'contents' }}
|
|
77
|
+
// biome-ignore lint/security/noDangerouslySetInnerHtml: markup React just rendered for this island
|
|
78
|
+
dangerouslySetInnerHTML={{ __html: html }}
|
|
79
|
+
/>
|
|
80
|
+
)
|
|
81
|
+
}
|
|
82
|
+
return /** @type {T} */ (/** @type {unknown} */ (Island))
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* @param {unknown} value
|
|
87
|
+
* @param {string} type - A `$$typeof` symbol description, e.g. `react.memo`
|
|
88
|
+
*/
|
|
89
|
+
function isType(value, type) {
|
|
90
|
+
return (
|
|
91
|
+
typeof value === 'object' &&
|
|
92
|
+
value !== null &&
|
|
93
|
+
/** @type {any} */ (value).$$typeof === Symbol.for(type)
|
|
94
|
+
)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* `memo()` and `forwardRef()` components are objects, not functions.
|
|
99
|
+
*
|
|
100
|
+
* @param {unknown} value
|
|
101
|
+
*/
|
|
102
|
+
function isComponent(value) {
|
|
103
|
+
return (
|
|
104
|
+
typeof value === 'function' ||
|
|
105
|
+
isType(value, 'react.memo') ||
|
|
106
|
+
isType(value, 'react.forward_ref')
|
|
107
|
+
)
|
|
108
|
+
}
|
package/lib/orga.d.ts
CHANGED
|
@@ -8,5 +8,5 @@ export function setupOrga({ containerClass, root, rehypePlugins }: {
|
|
|
8
8
|
containerClass: string | string[];
|
|
9
9
|
root: string;
|
|
10
10
|
rehypePlugins?: import("unified").PluggableList | undefined;
|
|
11
|
-
}): import("@orgajs/rollup").Plugin;
|
|
11
|
+
}): (import("vite").Plugin<any> | import("@orgajs/rollup").Plugin)[];
|
|
12
12
|
//# sourceMappingURL=orga.d.ts.map
|
package/lib/orga.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"orga.d.ts","sourceRoot":"","sources":["orga.js"],"names":[],"mappings":"AAQA;;;;;GAKG;AACH,mEAJG;IAAiC,cAAc,EAAvC,MAAM,GAAC,MAAM,EAAE;IACC,IAAI,EAApB,MAAM;IACoC,aAAa;CACjE,
|
|
1
|
+
{"version":3,"file":"orga.d.ts","sourceRoot":"","sources":["orga.js"],"names":[],"mappings":"AAQA;;;;;GAKG;AACH,mEAJG;IAAiC,cAAc,EAAvC,MAAM,GAAC,MAAM,EAAE;IACC,IAAI,EAApB,MAAM;IACoC,aAAa;CACjE,oEAsBA"}
|
package/lib/orga.js
CHANGED
|
@@ -13,14 +13,26 @@ import { getSlugFromContentFilePath } from './files.js'
|
|
|
13
13
|
* @param {import('unified').PluggableList} [options.rehypePlugins] - Extra rehype plugins appended to defaults
|
|
14
14
|
*/
|
|
15
15
|
export function setupOrga({ containerClass, root, rehypePlugins = [] }) {
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
16
|
+
/** Vite's `base`, set once the config resolves. */
|
|
17
|
+
const site = { base: '/' }
|
|
18
|
+
/** @type {import('vite').Plugin} */
|
|
19
|
+
const base = {
|
|
20
|
+
name: 'orga-build:base',
|
|
21
|
+
configResolved(config) {
|
|
22
|
+
site.base = config.base
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return [
|
|
26
|
+
base,
|
|
27
|
+
_orga({
|
|
28
|
+
rehypePlugins: [
|
|
29
|
+
[rehypeWrap, { className: containerClass }],
|
|
30
|
+
[rewriteOrgFileLinks, { root, site }],
|
|
31
|
+
mediaAssets,
|
|
32
|
+
...rehypePlugins
|
|
33
|
+
]
|
|
34
|
+
})
|
|
35
|
+
]
|
|
24
36
|
}
|
|
25
37
|
|
|
26
38
|
// --- plugins ---
|
|
@@ -33,16 +45,19 @@ function mediaAssets() {
|
|
|
33
45
|
/** @type {Record<string, string>} */
|
|
34
46
|
const imports = {}
|
|
35
47
|
visitParents(tree, [{ tagName: 'img' }, { tagName: 'video' }], (node) => {
|
|
36
|
-
node.type = 'jsx'
|
|
37
48
|
const { src, ...rest } = node.properties
|
|
38
49
|
if (typeof src !== 'string') return
|
|
39
|
-
|
|
50
|
+
// A scheme (`https:`, `data:`) or `//host` means it isn't a local file.
|
|
51
|
+
if (/^([a-z][a-z\d+.-]*:|\/\/)/i.test(src)) return
|
|
52
|
+
node.type = 'jsx'
|
|
40
53
|
const tagName = node.tagName
|
|
41
54
|
if (!imports[src]) imports[src] = `asset_${genId()}`
|
|
42
55
|
const name = imports[src]
|
|
43
56
|
const attrs = Object.entries(rest)
|
|
44
57
|
.filter(([, v]) => v !== undefined && v !== false)
|
|
45
|
-
.map(([k, v]) =>
|
|
58
|
+
.map(([k, v]) =>
|
|
59
|
+
v === true ? k : `${k}={${JSON.stringify(String(v))}}`
|
|
60
|
+
)
|
|
46
61
|
.join(' ')
|
|
47
62
|
node.value = `<${tagName} src={${name}}${attrs ? ` ${attrs}` : ''}/>`
|
|
48
63
|
})
|
|
@@ -50,7 +65,7 @@ function mediaAssets() {
|
|
|
50
65
|
for (const [src, name] of Object.entries(imports)) {
|
|
51
66
|
tree.children.unshift({
|
|
52
67
|
type: 'jsx',
|
|
53
|
-
value: `import ${name} from
|
|
68
|
+
value: `import ${name} from ${JSON.stringify(src)}`,
|
|
54
69
|
children: []
|
|
55
70
|
})
|
|
56
71
|
}
|
|
@@ -89,10 +104,13 @@ function rehypeWrap({ className = [] }) {
|
|
|
89
104
|
}
|
|
90
105
|
|
|
91
106
|
/**
|
|
107
|
+
* Point links to `.org` files at their pages, keeping any `#fragment`.
|
|
108
|
+
*
|
|
92
109
|
* @param {Object} options
|
|
93
110
|
* @param {string} options.root
|
|
111
|
+
* @param {{ base: string }} options.site
|
|
94
112
|
*/
|
|
95
|
-
function rewriteOrgFileLinks({ root }) {
|
|
113
|
+
function rewriteOrgFileLinks({ root, site }) {
|
|
96
114
|
/**
|
|
97
115
|
* @param {any} tree
|
|
98
116
|
* @param {import('vfile').VFile} [file]
|
|
@@ -104,16 +122,36 @@ function rewriteOrgFileLinks({ root }) {
|
|
|
104
122
|
visitParents(tree, { tagName: 'a' }, (node) => {
|
|
105
123
|
const href = node?.properties?.href
|
|
106
124
|
if (typeof href !== 'string') return
|
|
107
|
-
if (
|
|
125
|
+
if (/^[a-z][a-z\d+.-]*:/i.test(href)) return
|
|
126
|
+
const hashIndex = href.indexOf('#')
|
|
127
|
+
const target = hashIndex === -1 ? href : href.slice(0, hashIndex)
|
|
128
|
+
const hash = hashIndex === -1 ? '' : href.slice(hashIndex)
|
|
129
|
+
if (!target.endsWith('.org')) return
|
|
108
130
|
|
|
109
131
|
const targetSlug = resolveOrgHrefToContentSlug({
|
|
110
132
|
root,
|
|
111
133
|
filePath,
|
|
112
|
-
href
|
|
134
|
+
href: target
|
|
113
135
|
})
|
|
114
136
|
if (!targetSlug) return
|
|
115
|
-
node.properties.href = targetSlug
|
|
137
|
+
node.properties.href = pageUrl(targetSlug) + hash
|
|
116
138
|
})
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* URL of the page at `slug`. With a relative `base`, it is relative to
|
|
142
|
+
* this page's directory, as pages are written to `<slug>/index.html`.
|
|
143
|
+
* @param {string} slug
|
|
144
|
+
*/
|
|
145
|
+
function pageUrl(slug) {
|
|
146
|
+
const { base } = site
|
|
147
|
+
if ((base === './' || base === '') && filePath) {
|
|
148
|
+
const depth = getSlugFromContentFilePath(path.relative(root, filePath))
|
|
149
|
+
.split('/')
|
|
150
|
+
.filter(Boolean).length
|
|
151
|
+
return (depth ? '../'.repeat(depth) : './') + slug.slice(1)
|
|
152
|
+
}
|
|
153
|
+
return base.replace(/\/$/, '') + slug
|
|
154
|
+
}
|
|
117
155
|
}
|
|
118
156
|
}
|
|
119
157
|
|
package/lib/plugin.d.ts
CHANGED
|
@@ -1,59 +1,44 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @typedef {Object} OrgaBuildPluginOptions
|
|
3
3
|
* @property {string} root - Root directory for content files
|
|
4
|
-
* @property {string | undefined} [outDir] - Output directory (
|
|
4
|
+
* @property {string | undefined} [outDir] - Output directory (default: Vite's `build.outDir`)
|
|
5
5
|
* @property {string|string[]} [containerClass] - CSS class(es) to wrap rendered content
|
|
6
|
-
* @property {string[]} [styles] - Global stylesheet URLs to
|
|
6
|
+
* @property {string[]} [styles] - Global stylesheet URLs to link from the HTML shell
|
|
7
7
|
* @property {import('unified').PluggableList} [rehypePlugins] - Extra rehype plugins appended to orga-build defaults
|
|
8
|
+
* @property {string[]} [exclude] - Glob patterns for files to exclude from content scanning
|
|
9
|
+
* @property {string | undefined} [site] - Absolute URL the site is served from, e.g. `https://example.com`
|
|
8
10
|
*/
|
|
9
11
|
/**
|
|
10
|
-
* Creates the
|
|
11
|
-
*
|
|
12
|
+
* Creates the orga-build plugin preset: everything needed for `vite` (dev SSR)
|
|
13
|
+
* and `vite build` (static site) to work, used by the CLI and usable directly
|
|
14
|
+
* in a `vite.config.js`.
|
|
12
15
|
*
|
|
13
16
|
* @param {OrgaBuildPluginOptions} options
|
|
14
17
|
* @returns {import('vite').PluginOption[]}
|
|
15
18
|
*/
|
|
16
|
-
export function orgaBuildPlugin({ root, outDir, containerClass, styles, rehypePlugins }: OrgaBuildPluginOptions): import("vite").PluginOption[];
|
|
19
|
+
export function orgaBuildPlugin({ root, outDir, containerClass, styles, rehypePlugins, exclude, site }: OrgaBuildPluginOptions): import("vite").PluginOption[];
|
|
17
20
|
/**
|
|
18
21
|
* Creates the full Vite config options for orga-build.
|
|
19
|
-
* Includes plugins, resolve aliases, and other shared config.
|
|
20
22
|
*
|
|
21
|
-
* @param {OrgaBuildPluginOptions & {
|
|
22
|
-
* @returns {{ plugins: import('vite').PluginOption[]
|
|
23
|
+
* @param {OrgaBuildPluginOptions & { vitePlugins?: import('vite').PluginOption[] }} options
|
|
24
|
+
* @returns {{ plugins: import('vite').PluginOption[] }}
|
|
23
25
|
*/
|
|
24
|
-
export function createOrgaBuildConfig({
|
|
25
|
-
outDir?: string;
|
|
26
|
+
export function createOrgaBuildConfig({ vitePlugins, ...options }: OrgaBuildPluginOptions & {
|
|
26
27
|
vitePlugins?: import("vite").PluginOption[];
|
|
27
|
-
includeFallbackHtml?: boolean;
|
|
28
|
-
projectRoot?: string;
|
|
29
28
|
}): {
|
|
30
29
|
plugins: import("vite").PluginOption[];
|
|
31
|
-
resolve: {
|
|
32
|
-
alias: typeof alias;
|
|
33
|
-
};
|
|
34
30
|
};
|
|
35
31
|
/**
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
* This plugin performs per-request SSR in dev mode (matching Astro/SvelteKit behaviour):
|
|
39
|
-
* - SSR-renders each page on every request using Vite's server module runner
|
|
40
|
-
* - Injects rendered content and page metadata (%orga.*% placeholders) into the template
|
|
41
|
-
* - Falls back to the shell HTML for unknown routes (client-side router handles 404)
|
|
42
|
-
* - Only handles GET/HEAD requests that accept HTML
|
|
43
|
-
* - Does not intercept asset requests
|
|
44
|
-
*
|
|
45
|
-
* @param {string} projectRoot - Project root directory (where orga.config.js lives)
|
|
46
|
-
* @param {string[]} [styles]
|
|
47
|
-
* @returns {import('vite').Plugin}
|
|
32
|
+
* Prefix of emitted asset paths in server-rendered markup, replaced per page
|
|
33
|
+
* while prerendering (see `prerender.js`).
|
|
48
34
|
*/
|
|
49
|
-
export
|
|
35
|
+
export const assetUrlMarker: "/@orga-build/asset/";
|
|
50
36
|
/**
|
|
51
|
-
* Alias map for React
|
|
37
|
+
* Alias map for React to ensure a single copy is bundled
|
|
52
38
|
*/
|
|
53
39
|
export const alias: {
|
|
54
40
|
react: string;
|
|
55
41
|
'react-dom': string;
|
|
56
|
-
wouter: string;
|
|
57
42
|
};
|
|
58
43
|
export type OrgaBuildPluginOptions = {
|
|
59
44
|
/**
|
|
@@ -61,7 +46,7 @@ export type OrgaBuildPluginOptions = {
|
|
|
61
46
|
*/
|
|
62
47
|
root: string;
|
|
63
48
|
/**
|
|
64
|
-
* - Output directory (
|
|
49
|
+
* - Output directory (default: Vite's `build.outDir`)
|
|
65
50
|
*/
|
|
66
51
|
outDir?: string | undefined;
|
|
67
52
|
/**
|
|
@@ -69,12 +54,20 @@ export type OrgaBuildPluginOptions = {
|
|
|
69
54
|
*/
|
|
70
55
|
containerClass?: string | string[];
|
|
71
56
|
/**
|
|
72
|
-
* - Global stylesheet URLs to
|
|
57
|
+
* - Global stylesheet URLs to link from the HTML shell
|
|
73
58
|
*/
|
|
74
59
|
styles?: string[];
|
|
75
60
|
/**
|
|
76
61
|
* - Extra rehype plugins appended to orga-build defaults
|
|
77
62
|
*/
|
|
78
63
|
rehypePlugins?: import("unified").PluggableList;
|
|
64
|
+
/**
|
|
65
|
+
* - Glob patterns for files to exclude from content scanning
|
|
66
|
+
*/
|
|
67
|
+
exclude?: string[];
|
|
68
|
+
/**
|
|
69
|
+
* - Absolute URL the site is served from, e.g. `https://example.com`
|
|
70
|
+
*/
|
|
71
|
+
site?: string | undefined;
|
|
79
72
|
};
|
|
80
73
|
//# sourceMappingURL=plugin.d.ts.map
|