Use Norn for the shared graph SPA
This commit is contained in:
parent
218e9cb661
commit
1cf053a4d3
14 changed files with 201 additions and 91 deletions
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
node_modules/
|
||||
quartz/graph-spa/node_modules/
|
||||
quartz/graph-spa/dist/
|
||||
*.tsbuildinfo
|
||||
|
|
@ -8,6 +8,7 @@
|
|||
|
||||
- Keep site-specific copy, branding, mastheads, sidebars, and layout quirks in the consumer repo overlays.
|
||||
- Shared engine changes should be generic by default and useful to more than one site.
|
||||
- Norn owns reusable graph interaction and rendering. `quartz/graph-spa` owns only the generic Quartz content-index projection, article navigation, and shell; consumer taxonomy, filtering, and styling must arrive through overlay configuration.
|
||||
- If a consumer repo needs custom page context, provide it through `site/quartz/components/sitePageContext.ts` in that repo instead of hardcoding the shared renderer.
|
||||
- Do not point sample config at live production domains.
|
||||
- Prefer reusable GitHub Actions workflows here over copy-pasted Pages workflows in every site repo.
|
||||
|
|
|
|||
18
README.md
18
README.md
|
|
@ -45,15 +45,25 @@ node ..\\GameCult-Quartz\\scripts\\build-site.mjs build --siteRoot E:\\Projects\
|
|||
Consumer sites can render the shared graph surface with `Component.GameCultGraphSpaShell(...)`.
|
||||
The component only owns the mount point and asset tags; the consumer overlay decides where it appears.
|
||||
|
||||
The shell's default `epiphany-graph` asset route and mount class remain a compatibility contract for
|
||||
the deployed Mimir and gamecult.org sites. New or migrated consumers should pass the
|
||||
`norn-graph` asset paths and `gamecult-norn-graph-root` explicitly.
|
||||
|
||||
Build the static SPA assets into the consumer repo when its graph payload changes:
|
||||
|
||||
```powershell
|
||||
node ..\\GameCult-Quartz\\scripts\\build-graph-spa.mjs --siteRoot E:\\Projects\\Mimir --outputDir static/epiphany-graph
|
||||
node ..\\GameCult-Quartz\\scripts\\build-graph-spa.mjs --siteRoot E:\\Projects\\Mimir --outputDir static/norn-graph
|
||||
```
|
||||
|
||||
The SPA source lives in `quartz/graph-spa`. It currently imports the shared `EpiphanyGraphViewer`
|
||||
from a sibling `EpiphanyGraph` checkout during local builds, so generated assets should be committed
|
||||
by consumer sites until that viewer is packaged as a normal dependency.
|
||||
The SPA source lives in `quartz/graph-spa`. It consumes the admitted build of
|
||||
`@gamecult/norn-viewer` from a sibling `Norn` checkout. The builder verifies the
|
||||
revision in `quartz/graph-spa/norn-revision.txt`, builds the Norn package, and then
|
||||
builds the Quartz adapter. Generated assets remain committed by consumer sites.
|
||||
|
||||
GameCult-Quartz owns content-index projection, article loading, hash navigation,
|
||||
and the fullscreen shell. Norn owns graph interaction and rendering. Consumer
|
||||
sites provide taxonomy, filtering, placement, and presentation through
|
||||
`GameCultGraphSpaShell` configuration and overlay styles.
|
||||
|
||||
## Shared Interactive Embeds
|
||||
|
||||
|
|
|
|||
29
package-lock.json
generated
29
package-lock.json
generated
|
|
@ -86,6 +86,8 @@
|
|||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/node": "^25.0.10",
|
||||
"@types/pretty-time": "^1.1.5",
|
||||
"@types/react": "^19.2.2",
|
||||
"@types/react-dom": "^19.2.2",
|
||||
"@types/source-map-support": "^0.5.10",
|
||||
"@types/ws": "^8.18.1",
|
||||
"@types/yargs": "^17.0.35",
|
||||
|
|
@ -3163,6 +3165,26 @@
|
|||
"integrity": "sha512-5yl+BYwmnRWZb783W8YYoHXvPY8q/rp7ctHBVaGBB9RxlzGpHNJ72tGQMK7TrUSnxzl1dbDcBDuBCSbtfnSQGg==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@types/react": {
|
||||
"version": "19.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz",
|
||||
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"csstype": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react-dom": {
|
||||
"version": "19.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.2.tgz",
|
||||
"integrity": "sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "^19.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/source-map-support": {
|
||||
"version": "0.5.10",
|
||||
"resolved": "https://registry.npmjs.org/@types/source-map-support/-/source-map-support-0.5.10.tgz",
|
||||
|
|
@ -3655,6 +3677,13 @@
|
|||
"postcss-value-parser": "^4.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/csstype": {
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/d3": {
|
||||
"version": "7.9.0",
|
||||
"resolved": "https://registry.npmjs.org/d3/-/d3-7.9.0.tgz",
|
||||
|
|
|
|||
|
|
@ -109,6 +109,8 @@
|
|||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/node": "^25.0.10",
|
||||
"@types/pretty-time": "^1.1.5",
|
||||
"@types/react": "^19.2.2",
|
||||
"@types/react-dom": "^19.2.2",
|
||||
"@types/source-map-support": "^0.5.10",
|
||||
"@types/ws": "^8.18.1",
|
||||
"@types/yargs": "^17.0.35",
|
||||
|
|
|
|||
|
|
@ -7,13 +7,19 @@ export type GameCultGraphSpaShellOptions = {
|
|||
config?: {
|
||||
title?: string
|
||||
architectureDescription?: string
|
||||
layoutMode?: "layered" | "stress" | "force" | "combined-force" | {
|
||||
architecture?: "layered" | "stress" | "force" | "combined-force"
|
||||
dataflow?: "layered" | "stress" | "force" | "combined-force"
|
||||
}
|
||||
layoutMode?:
|
||||
| "layered"
|
||||
| "stress"
|
||||
| "force"
|
||||
| "combined-force"
|
||||
| {
|
||||
architecture?: "layered" | "stress" | "force" | "combined-force"
|
||||
dataflow?: "layered" | "stress" | "force" | "combined-force"
|
||||
}
|
||||
allowedSlugPrefixes?: string[]
|
||||
blockedSlugPrefixes?: string[]
|
||||
blockedPathSegments?: string[]
|
||||
sectionOrder?: string[]
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
1
quartz/graph-spa/norn-revision.txt
Normal file
1
quartz/graph-spa/norn-revision.txt
Normal file
|
|
@ -0,0 +1 @@
|
|||
fa4a46bb13333eb703e97ccea9edc1b70ee109b7
|
||||
|
|
@ -8,7 +8,6 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
"elkjs": "^0.11.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"typescript": "^5.9.3",
|
||||
|
|
|
|||
|
|
@ -1,16 +1,16 @@
|
|||
import React from "react"
|
||||
import { flushSync } from "react-dom"
|
||||
import ReactDOM from "react-dom/client"
|
||||
import { EpiphanyGraphViewer } from "../../../../EpiphanyGraph/web/epiphany-graph-viewer/src/lib/EpiphanyGraphViewer"
|
||||
import { NornViewer } from "@gamecult/norn-viewer"
|
||||
import type {
|
||||
EpiphanyGraphEdge,
|
||||
EpiphanyGraphNode,
|
||||
EpiphanyGraphLayoutModeConfig,
|
||||
EpiphanyGraphsState,
|
||||
NornGraphEdge,
|
||||
NornGraphNode,
|
||||
NornGraphLayoutModeConfig,
|
||||
NornGraphsState,
|
||||
GraphKey,
|
||||
PositionedNode,
|
||||
ViewerSelection,
|
||||
} from "../../../../EpiphanyGraph/web/epiphany-graph-viewer/src/lib/types"
|
||||
} from "@gamecult/norn-viewer"
|
||||
import "./styles.css"
|
||||
|
||||
type QuartzContentEntry = {
|
||||
|
|
@ -27,7 +27,8 @@ type QuartzContentIndex = Record<string, QuartzContentEntry>
|
|||
type GraphSpaConfig = {
|
||||
title?: string
|
||||
architectureDescription?: string
|
||||
layoutMode?: EpiphanyGraphLayoutModeConfig
|
||||
layoutMode?: NornGraphLayoutModeConfig
|
||||
sectionOrder?: string[]
|
||||
allowedSlugPrefixes?: string[]
|
||||
blockedSlugPrefixes?: string[]
|
||||
blockedPathSegments?: string[]
|
||||
|
|
@ -59,7 +60,6 @@ declare global {
|
|||
}
|
||||
}
|
||||
|
||||
const DEFAULT_BLOCKED_SLUG_PREFIXES = ["Inspirations/", "GameCult-Quartz/"]
|
||||
const DEFAULT_BLOCKED_PATH_SEGMENTS = [
|
||||
"/.git/",
|
||||
"/node_modules/",
|
||||
|
|
@ -94,17 +94,6 @@ class GraphErrorBoundary extends React.Component<React.PropsWithChildren, GraphE
|
|||
}
|
||||
}
|
||||
|
||||
const SECTION_ORDER = [
|
||||
"World",
|
||||
"Ecology",
|
||||
"Species",
|
||||
"Civilizations",
|
||||
"Technologies",
|
||||
"Conflicts",
|
||||
"Themes",
|
||||
"Root",
|
||||
]
|
||||
|
||||
function entryTitle(slug: string, entry?: QuartzContentEntry) {
|
||||
if (entry?.title) {
|
||||
return entry.title
|
||||
|
|
@ -127,7 +116,7 @@ function normalizeSlug(slug: string) {
|
|||
|
||||
function graphSpaConfig() {
|
||||
const mountedConfig = document.querySelector<HTMLElement>(
|
||||
".gamecult-epiphany-graph-root[data-graph-config]",
|
||||
".gamecult-norn-graph-root[data-graph-config], .gamecult-epiphany-graph-root[data-graph-config]",
|
||||
)?.dataset.graphConfig
|
||||
|
||||
if (mountedConfig) {
|
||||
|
|
@ -149,7 +138,7 @@ function slugMatchesPrefix(slug: string, prefix: string) {
|
|||
|
||||
function isAllowedGraphSlug(slug: string, entry: QuartzContentEntry, config = graphSpaConfig()) {
|
||||
const normalizedSlug = normalizeSlug(slug)
|
||||
const blockedPrefixes = [...DEFAULT_BLOCKED_SLUG_PREFIXES, ...(config.blockedSlugPrefixes ?? [])]
|
||||
const blockedPrefixes = config.blockedSlugPrefixes ?? []
|
||||
|
||||
if (blockedPrefixes.some((prefix) => slugMatchesPrefix(normalizedSlug, prefix))) {
|
||||
return false
|
||||
|
|
@ -240,13 +229,13 @@ function currentRouteSelection(slugs: Set<string>): ViewerSelection | null {
|
|||
}
|
||||
}
|
||||
|
||||
function compareSections(left: string, right: string) {
|
||||
const leftIndex = SECTION_ORDER.indexOf(left)
|
||||
const rightIndex = SECTION_ORDER.indexOf(right)
|
||||
function compareSections(left: string, right: string, sectionOrder: string[]) {
|
||||
const leftIndex = sectionOrder.indexOf(left)
|
||||
const rightIndex = sectionOrder.indexOf(right)
|
||||
|
||||
if (leftIndex !== -1 || rightIndex !== -1) {
|
||||
const orderedLeft = leftIndex === -1 ? SECTION_ORDER.length : leftIndex
|
||||
const orderedRight = rightIndex === -1 ? SECTION_ORDER.length : rightIndex
|
||||
const orderedLeft = leftIndex === -1 ? sectionOrder.length : leftIndex
|
||||
const orderedRight = rightIndex === -1 ? sectionOrder.length : rightIndex
|
||||
|
||||
return orderedLeft - orderedRight
|
||||
}
|
||||
|
|
@ -324,11 +313,12 @@ function buildSectionEdges(entries: Array<[string, QuartzContentEntry]>, slugs:
|
|||
sourceSection === targetSection
|
||||
? `${count} wiki ${count === 1 ? "link stays" : "links stay"} inside ${sourceSection}.`
|
||||
: `${count} wiki ${count === 1 ? "link runs" : "links run"} from ${sourceSection} into ${targetSection}.`,
|
||||
} satisfies EpiphanyGraphEdge
|
||||
} satisfies NornGraphEdge
|
||||
})
|
||||
}
|
||||
|
||||
function buildGraphState(contentIndex: QuartzContentIndex): EpiphanyGraphsState {
|
||||
function buildGraphState(contentIndex: QuartzContentIndex): NornGraphsState {
|
||||
const config = graphSpaConfig()
|
||||
const entries = Object.entries(contentIndex)
|
||||
.map(([slug, entry]) => {
|
||||
const normalizedSlug = normalizeSlug(entry.slug || slug)
|
||||
|
|
@ -342,7 +332,7 @@ function buildGraphState(contentIndex: QuartzContentIndex): EpiphanyGraphsState
|
|||
const slugs = new Set(entries.map(([slug]) => slug))
|
||||
const incoming = buildIncomingLinks(entries, slugs)
|
||||
|
||||
const architectureNodes: EpiphanyGraphNode[] = entries.map(([slug, entry]) => {
|
||||
const architectureNodes: NornGraphNode[] = entries.map(([slug, entry]) => {
|
||||
const outgoingCount = validLinkedSlugs(entry, slugs).length
|
||||
const backlinkCount = incoming.get(slug)?.length ?? 0
|
||||
|
||||
|
|
@ -356,7 +346,7 @@ function buildGraphState(contentIndex: QuartzContentIndex): EpiphanyGraphsState
|
|||
}
|
||||
})
|
||||
|
||||
const architectureEdges: EpiphanyGraphEdge[] = entries.flatMap(([sourceSlug, entry]) =>
|
||||
const architectureEdges: NornGraphEdge[] = entries.flatMap(([sourceSlug, entry]) =>
|
||||
validLinkedSlugs(entry, slugs).map((targetSlug, index) => ({
|
||||
id: `${sourceSlug}->${targetSlug}:${index}`,
|
||||
source_id: sourceSlug,
|
||||
|
|
@ -367,10 +357,10 @@ function buildGraphState(contentIndex: QuartzContentIndex): EpiphanyGraphsState
|
|||
})),
|
||||
)
|
||||
|
||||
const sections = Array.from(new Set(entries.map(([slug]) => sectionForSlug(slug)))).sort(
|
||||
compareSections,
|
||||
const sections = Array.from(new Set(entries.map(([slug]) => sectionForSlug(slug))).values()).sort(
|
||||
(left, right) => compareSections(left, right, config.sectionOrder ?? []),
|
||||
)
|
||||
const dataflowNodes: EpiphanyGraphNode[] = sections.map((section) => {
|
||||
const dataflowNodes: NornGraphNode[] = sections.map((section) => {
|
||||
const sectionEntries = entries.filter(([slug]) => sectionForSlug(slug) === section)
|
||||
const sectionSlugs = new Set(sectionEntries.map(([slug]) => slug))
|
||||
const outgoingCrossSection = sectionEntries.reduce((count, [, entry]) => {
|
||||
|
|
@ -470,7 +460,7 @@ function selectedNoteSlug(selection: ViewerSelection | null) {
|
|||
}
|
||||
|
||||
function App() {
|
||||
const [graphState, setGraphState] = React.useState<EpiphanyGraphsState | null>(null)
|
||||
const [graphState, setGraphState] = React.useState<NornGraphsState | null>(null)
|
||||
const [contentIndex, setContentIndex] = React.useState<QuartzContentIndex | null>(null)
|
||||
const [selection, setSelection] = React.useState<ViewerSelection | null>(null)
|
||||
const [viewportTarget, setViewportTarget] = React.useState<ViewerSelection | null>(null)
|
||||
|
|
@ -635,16 +625,16 @@ function App() {
|
|||
|
||||
if (!graphState) {
|
||||
return (
|
||||
<main className="gamecult-epiphany-graph-app">
|
||||
<main className="gamecult-norn-graph-app">
|
||||
<div className="gamecult-graph-loading">Loading Quartz backlink graph...</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="gamecult-epiphany-graph-app">
|
||||
<main className="gamecult-norn-graph-app">
|
||||
<GraphErrorBoundary>
|
||||
<EpiphanyGraphViewer
|
||||
<NornViewer
|
||||
state={graphState}
|
||||
title={graphSpaConfig().title ?? "GameCult Knowledge Graph"}
|
||||
selection={selection}
|
||||
|
|
@ -829,7 +819,6 @@ function ArticlePanel({
|
|||
{articleState.status === "ready" && (
|
||||
<div
|
||||
className="gamecult-spa-article"
|
||||
data-epiphany-article-content
|
||||
onClick={onArticleLinkClick}
|
||||
dangerouslySetInnerHTML={{ __html: articleState.html }}
|
||||
/>
|
||||
|
|
@ -837,7 +826,6 @@ function ArticlePanel({
|
|||
{articleState.status !== "ready" && articleState.html && (
|
||||
<div
|
||||
className="gamecult-spa-article"
|
||||
data-epiphany-article-content
|
||||
onClick={onArticleLinkClick}
|
||||
dangerouslySetInnerHTML={{ __html: articleState.html }}
|
||||
/>
|
||||
|
|
@ -863,25 +851,27 @@ function initialsForTitle(title: string) {
|
|||
}
|
||||
|
||||
function mountGameCultGraph() {
|
||||
document.querySelectorAll(".gamecult-epiphany-graph-root").forEach((element) => {
|
||||
if (!(element instanceof HTMLElement)) {
|
||||
return
|
||||
}
|
||||
document
|
||||
.querySelectorAll(".gamecult-norn-graph-root, .gamecult-epiphany-graph-root")
|
||||
.forEach((element) => {
|
||||
if (!(element instanceof HTMLElement)) {
|
||||
return
|
||||
}
|
||||
|
||||
if (element.dataset.ready === "true" && element.childElementCount > 0) {
|
||||
return
|
||||
}
|
||||
if (element.dataset.ready === "true" && element.childElementCount > 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const root = ReactDOM.createRoot(element)
|
||||
flushSync(() => {
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
)
|
||||
const root = ReactDOM.createRoot(element)
|
||||
flushSync(() => {
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
)
|
||||
})
|
||||
element.dataset.ready = "true"
|
||||
})
|
||||
element.dataset.ready = "true"
|
||||
})
|
||||
}
|
||||
|
||||
function scheduleGameCultGraphMount() {
|
||||
|
|
|
|||
|
|
@ -15,17 +15,18 @@ body {
|
|||
font-family: "Ubuntu", "Segoe UI", sans-serif;
|
||||
}
|
||||
|
||||
.gamecult-epiphany-graph-app {
|
||||
.gamecult-norn-graph-app {
|
||||
box-sizing: border-box;
|
||||
width: 100vw;
|
||||
min-height: 100vh;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.gamecult-epiphany-graph-app * {
|
||||
.gamecult-norn-graph-app * {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.gamecult-norn-graph-root,
|
||||
.gamecult-epiphany-graph-root {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
|
|
@ -65,7 +66,7 @@ body.gamecult-graph-spa-active {
|
|||
height: 100vh;
|
||||
}
|
||||
|
||||
.gamecult-graph-shell .epiphany-graph-overlay-panel {
|
||||
.gamecult-graph-shell .norn-overlay-panel {
|
||||
opacity: 0.25;
|
||||
transition:
|
||||
opacity 160ms ease,
|
||||
|
|
@ -73,17 +74,17 @@ body.gamecult-graph-spa-active {
|
|||
box-shadow 160ms ease;
|
||||
}
|
||||
|
||||
.gamecult-graph-shell .epiphany-graph-overlay-panel:hover,
|
||||
.gamecult-graph-shell .epiphany-graph-overlay-panel:focus-within {
|
||||
.gamecult-graph-shell .norn-overlay-panel:hover,
|
||||
.gamecult-graph-shell .norn-overlay-panel:focus-within {
|
||||
opacity: 1;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.gamecult-graph-shell aside.epiphany-graph-overlay-panel > section {
|
||||
.gamecult-graph-shell aside.norn-overlay-panel > section {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.gamecult-graph-shell aside.epiphany-graph-overlay-panel > section:last-child {
|
||||
.gamecult-graph-shell aside.norn-overlay-panel > section:last-child {
|
||||
display: grid !important;
|
||||
grid-row: 1 / -1;
|
||||
}
|
||||
|
|
@ -266,7 +267,7 @@ body.gamecult-graph-spa-active {
|
|||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.gamecult-graph-shell aside.epiphany-graph-overlay-panel {
|
||||
.gamecult-graph-shell aside.norn-overlay-panel {
|
||||
left: 12px;
|
||||
right: 12px;
|
||||
top: auto !important;
|
||||
|
|
|
|||
|
|
@ -3,13 +3,15 @@
|
|||
"compilerOptions": {
|
||||
"jsx": "react-jsx",
|
||||
"jsxImportSource": "react",
|
||||
"types": ["react", "react-dom"]
|
||||
"types": ["react", "react-dom"],
|
||||
"paths": {
|
||||
"@gamecult/norn-viewer": ["../../../Norn/web/norn-viewer/dist/index.d.ts"]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"./src/**/*.ts",
|
||||
"./src/**/*.tsx",
|
||||
"./vite.config.ts",
|
||||
"../../../EpiphanyGraph/web/epiphany-graph-viewer/src/**/*.ts",
|
||||
"../../../EpiphanyGraph/web/epiphany-graph-viewer/src/**/*.tsx"
|
||||
"../../../Norn/web/norn-viewer/dist/index.d.ts"
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
<title>gamecult Fractal Eusocial Web</title>
|
||||
</head>
|
||||
<body>
|
||||
<div class="gamecult-epiphany-graph-root"></div>
|
||||
<div class="gamecult-norn-graph-root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -6,6 +6,10 @@ import path from "node:path"
|
|||
const viewerEntry = fileURLToPath(new URL("./viewer.html", import.meta.url))
|
||||
const reactPath = fileURLToPath(new URL("./node_modules/react", import.meta.url))
|
||||
const reactDomPath = fileURLToPath(new URL("./node_modules/react-dom", import.meta.url))
|
||||
const nornViewerRoot = process.env.GAMECULT_NORN_VIEWER_ROOT
|
||||
if (!nornViewerRoot) {
|
||||
throw new Error("GAMECULT_NORN_VIEWER_ROOT is required. Use scripts/build-graph-spa.mjs.")
|
||||
}
|
||||
const outDir = process.env.GAMECULT_GRAPH_SPA_OUT_DIR
|
||||
? path.resolve(process.env.GAMECULT_GRAPH_SPA_OUT_DIR)
|
||||
: fileURLToPath(new URL("./dist", import.meta.url))
|
||||
|
|
@ -16,6 +20,7 @@ export default defineConfig({
|
|||
alias: {
|
||||
react: reactPath,
|
||||
"react-dom": reactDomPath,
|
||||
"@gamecult/norn-viewer": path.join(nornViewerRoot, "dist", "index.js"),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
|
|
@ -32,7 +37,6 @@ export default defineConfig({
|
|||
output: {
|
||||
manualChunks: {
|
||||
react: ["react", "react-dom"],
|
||||
elk: ["elkjs/lib/elk.bundled.js"],
|
||||
},
|
||||
entryFileNames: "assets/[name].js",
|
||||
chunkFileNames: "assets/[name].js",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import fs from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { spawn } from "node:child_process"
|
||||
import { execFileSync } from "node:child_process"
|
||||
import { fileURLToPath } from "node:url"
|
||||
|
||||
const engineRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..")
|
||||
|
|
@ -9,6 +10,7 @@ function parseArgs(argv) {
|
|||
const options = {
|
||||
siteRoot: process.cwd(),
|
||||
outputDir: "static/epiphany-graph",
|
||||
nornRoot: path.resolve(engineRoot, "..", "Norn"),
|
||||
}
|
||||
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
|
|
@ -51,20 +53,69 @@ async function linkNodeModules(graphRoot) {
|
|||
await fs.symlink(source, dest, "junction")
|
||||
}
|
||||
|
||||
function runBuild(graphRoot, outputRoot) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(
|
||||
process.execPath,
|
||||
["./node_modules/vite/bin/vite.js", "build", "--base", "./"],
|
||||
{
|
||||
cwd: graphRoot,
|
||||
stdio: "inherit",
|
||||
env: {
|
||||
...process.env,
|
||||
GAMECULT_GRAPH_SPA_OUT_DIR: outputRoot,
|
||||
},
|
||||
},
|
||||
async function prepareNorn(nornRoot) {
|
||||
const viewerRoot = path.join(nornRoot, "web", "norn-viewer")
|
||||
const revisionPath = path.join(engineRoot, "quartz", "graph-spa", "norn-revision.txt")
|
||||
const expectedRevision = (await fs.readFile(revisionPath, "utf8")).trim()
|
||||
const packagePath = path.join(viewerRoot, "package.json")
|
||||
const tsupPath = path.join(viewerRoot, "node_modules", "tsup", "dist", "cli-default.js")
|
||||
|
||||
if (!(await exists(packagePath))) {
|
||||
throw new Error(
|
||||
`Norn was not found at '${nornRoot}'. Clone GameCult/Norn beside GameCult-Quartz or pass --nornRoot.`,
|
||||
)
|
||||
}
|
||||
|
||||
const actualRevision = execFileSync("git", ["-C", nornRoot, "rev-parse", "HEAD"], {
|
||||
encoding: "utf8",
|
||||
}).trim()
|
||||
if (actualRevision !== expectedRevision) {
|
||||
throw new Error(`Norn must be revision ${expectedRevision}; found ${actualRevision}.`)
|
||||
}
|
||||
|
||||
const status = execFileSync(
|
||||
"git",
|
||||
["-C", nornRoot, "status", "--porcelain", "--untracked-files=normal"],
|
||||
{ encoding: "utf8" },
|
||||
).trim()
|
||||
if (status) {
|
||||
throw new Error(`Norn must be clean before GameCult-Quartz consumes it: ${nornRoot}`)
|
||||
}
|
||||
|
||||
if (!(await exists(tsupPath))) {
|
||||
throw new Error(`Norn dependencies are missing. Run npm ci in '${viewerRoot}'.`)
|
||||
}
|
||||
|
||||
await runProcess(
|
||||
process.execPath,
|
||||
[
|
||||
tsupPath,
|
||||
"src/index.ts",
|
||||
"--format",
|
||||
"esm,cjs",
|
||||
"--dts",
|
||||
"--external",
|
||||
"react",
|
||||
"--external",
|
||||
"react-dom",
|
||||
"--clean",
|
||||
],
|
||||
viewerRoot,
|
||||
)
|
||||
|
||||
return viewerRoot
|
||||
}
|
||||
|
||||
function runProcess(command, args, cwd, extraEnv = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(command, args, {
|
||||
cwd,
|
||||
stdio: "inherit",
|
||||
env: {
|
||||
...process.env,
|
||||
...extraEnv,
|
||||
},
|
||||
})
|
||||
|
||||
child.on("exit", (code) => {
|
||||
if (code === 0) {
|
||||
|
|
@ -72,7 +123,7 @@ function runBuild(graphRoot, outputRoot) {
|
|||
return
|
||||
}
|
||||
|
||||
reject(new Error(`Graph SPA build exited with code ${code}`))
|
||||
reject(new Error(`${path.basename(command)} exited with code ${code}`))
|
||||
})
|
||||
child.on("error", reject)
|
||||
})
|
||||
|
|
@ -83,9 +134,19 @@ async function main() {
|
|||
const siteRoot = path.resolve(options.siteRoot)
|
||||
const outputRoot = path.resolve(siteRoot, options.outputDir)
|
||||
const graphRoot = path.join(engineRoot, "quartz", "graph-spa")
|
||||
const nornRoot = path.resolve(options.nornRoot)
|
||||
|
||||
const nornViewerRoot = await prepareNorn(nornRoot)
|
||||
await linkNodeModules(graphRoot)
|
||||
await runBuild(graphRoot, outputRoot)
|
||||
await runProcess(
|
||||
process.execPath,
|
||||
["./node_modules/vite/bin/vite.js", "build", "--base", "./"],
|
||||
graphRoot,
|
||||
{
|
||||
GAMECULT_GRAPH_SPA_OUT_DIR: outputRoot,
|
||||
GAMECULT_NORN_VIEWER_ROOT: nornViewerRoot,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue