diff --git a/GameCult/Studio/index.md b/GameCult/Studio/index.md index dff3912..9347cf4 100644 --- a/GameCult/Studio/index.md +++ b/GameCult/Studio/index.md @@ -1,5 +1,15 @@ --- title: Studio +enableToc: false +cssclasses: + - gamecult-composite-overview +compositeSections: + - Open-Source-Model + - Contributing + - democratizing-gamedev + - a-place-for-everyone + - games-as-a-service + - the-new-hotness --- # Studio @@ -8,6 +18,8 @@ title: Studio The studio section is where the older GameCult pitch turns into a durable shape: why the work is open, how people are meant to participate, what kind of projects fit here, and what the organization has historically promised to build around labor and governance. +The stitched studio narrative continues below. The standalone notes are still available from their own routes and in the sidebar. + ## How We Work - [Open Source Model](/Studio/Open-Source-Model) diff --git a/quartz-site/quartz.config.ts b/quartz-site/quartz.config.ts index 2f097ae..765d973 100644 --- a/quartz-site/quartz.config.ts +++ b/quartz-site/quartz.config.ts @@ -1,5 +1,6 @@ import { QuartzConfig } from "./quartz/cfg" import * as Plugin from "./quartz/plugins" +import * as Component from "./quartz/components" /** * Quartz 4 Configuration @@ -86,8 +87,12 @@ const config: QuartzConfig = { emitters: [ Plugin.AliasRedirects(), Plugin.ComponentResources(), - Plugin.ContentPage(), - Plugin.FolderPage(), + Plugin.ContentPage({ + pageBody: Component.GameCultCompositeContent({ fallback: "content" }), + }), + Plugin.FolderPage({ + pageBody: Component.GameCultCompositeContent({ fallback: "folder" }), + }), Plugin.ContentIndex({ enableSiteMap: true, enableRSS: true, diff --git a/quartz-site/quartz/components/GameCultCompositeContent.tsx b/quartz-site/quartz/components/GameCultCompositeContent.tsx new file mode 100644 index 0000000..468a769 --- /dev/null +++ b/quartz-site/quartz/components/GameCultCompositeContent.tsx @@ -0,0 +1,210 @@ +import { ComponentChildren } from "preact" +import { Element, ElementContent, Root } from "hast" +import { htmlToJsx } from "../util/jsx" +import { QuartzPluginData } from "../plugins/vfile" +import { clone } from "../util/clone" +import { normalizeHastElement, resolveRelative, type FullSlug } from "../util/path" +import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types" +import FolderContent from "./pages/FolderContent" + +interface Options { + fallback: "content" | "folder" +} + +const defaultOptions: Options = { + fallback: "content", +} + +type CompositeSection = { + content: ComponentChildren + leadFigure?: ComponentChildren + sectionId: string + slug: FullSlug + title: string +} + +function isElement(node: ElementContent): node is Element { + return node.type === "element" +} + +function folderSlug(slug: FullSlug) { + return slug === "index" ? "" : slug.replace(/\/index$/, "") +} + +function resolveCompositeSlug(currentSlug: FullSlug, value: string) { + const normalized = value + .trim() + .replace(/^\//, "") + .replace(/\.md$/i, "") + .replace(/\/$/, "") + + if (normalized.length === 0) { + return undefined + } + + if (normalized.includes("/")) { + return normalized as FullSlug + } + + const folder = folderSlug(currentSlug) + return (folder.length > 0 ? `${folder}/${normalized}` : normalized) as FullSlug +} + +function buildSectionContent(parentSlug: FullSlug, file: QuartzPluginData): CompositeSection | undefined { + if (!file.slug || !file.htmlAst || !file.filePath) { + return undefined + } + + const root = clone(file.htmlAst) as Root + const children = [...root.children] + + let firstMeaningfulIndex = -1 + for (let i = 0; i < children.length; i++) { + const node = children[i] + if (node.type === "text" && node.value.trim().length === 0) { + continue + } + + firstMeaningfulIndex = i + break + } + + if (firstMeaningfulIndex >= 0) { + const node = children[firstMeaningfulIndex] + if (isElement(node) && node.tagName === "h1") { + children.splice(firstMeaningfulIndex, 1) + } + } + + let leadFigure: Element | undefined + for (let i = 0; i < children.length; i++) { + const node = children[i] + if (node.type === "text" && node.value.trim().length === 0) { + continue + } + + if (isElement(node) && node.tagName === "figure") { + leadFigure = node + children.splice(i, 1) + } + break + } + + const normalizedChildren = children.map((child) => + normalizeHastElement(child as Element, parentSlug, file.slug as FullSlug), + ) as ElementContent[] + + const sectionRoot: Root = { + type: "root", + children: normalizedChildren, + } + + const normalizedFigure = leadFigure + ? normalizeHastElement(leadFigure, parentSlug, file.slug as FullSlug) + : undefined + + const figureRoot: Root | undefined = normalizedFigure + ? { + type: "root", + children: [normalizedFigure], + } + : undefined + + return { + content: htmlToJsx(file.filePath, sectionRoot) as ComponentChildren, + leadFigure: figureRoot ? (htmlToJsx(file.filePath, figureRoot) as ComponentChildren) : undefined, + sectionId: file.slug.split("/").at(-1)?.toLowerCase() ?? "section", + slug: file.slug as FullSlug, + title: file.frontmatter?.title ?? file.slug.split("/").at(-1) ?? "Section", + } +} + +function getCompositeSections( + fileData: QuartzPluginData, + allFiles: QuartzPluginData[], +): CompositeSection[] { + const currentSlug = fileData.slug as FullSlug | undefined + const configured = fileData.frontmatter?.compositeSections + + if (!currentSlug || !Array.isArray(configured) || configured.length === 0) { + return [] + } + + const bySlug = new Map( + allFiles + .filter((file) => typeof file.slug === "string") + .map((file) => [file.slug as FullSlug, file]), + ) + + return configured + .map((entry) => (typeof entry === "string" ? resolveCompositeSlug(currentSlug, entry) : undefined)) + .filter((entry): entry is FullSlug => entry !== undefined) + .map((slug) => bySlug.get(slug)) + .filter((file): file is QuartzPluginData => file !== undefined) + .map((file) => buildSectionContent(currentSlug, file)) + .filter((section): section is CompositeSection => section !== undefined) +} + +export default ((opts?: Partial) => { + const options: Options = { ...defaultOptions, ...opts } + const DefaultFolderContent = FolderContent() + + const GameCultCompositeContent: QuartzComponent = (props: QuartzComponentProps) => { + const { fileData, tree, allFiles } = props + const baseContent = htmlToJsx(fileData.filePath!, tree) as ComponentChildren + const classes: string[] = fileData.frontmatter?.cssclasses ?? [] + const articleClass = ["popover-hint", ...classes].join(" ") + const sections = getCompositeSections(fileData, allFiles) + + if (sections.length === 0) { + if (options.fallback === "folder") { + return + } + + return
{baseContent}
+ } + + return ( +
+
{baseContent}
+ +
+ {sections.map((section) => ( +
+
+ ))} +
+
+ ) + } + + return GameCultCompositeContent +}) satisfies QuartzComponentConstructor> diff --git a/quartz-site/quartz/components/index.ts b/quartz-site/quartz/components/index.ts index cece8e6..8e1f3b5 100644 --- a/quartz-site/quartz/components/index.ts +++ b/quartz-site/quartz/components/index.ts @@ -23,6 +23,7 @@ import Breadcrumbs from "./Breadcrumbs" import Comments from "./Comments" import Flex from "./Flex" import ConditionalRender from "./ConditionalRender" +import GameCultCompositeContent from "./GameCultCompositeContent" export { ArticleTitle, @@ -50,4 +51,5 @@ export { Comments, Flex, ConditionalRender, + GameCultCompositeContent, } diff --git a/quartz-site/quartz/styles/custom.scss b/quartz-site/quartz/styles/custom.scss index 7d5896e..df2f712 100644 --- a/quartz-site/quartz/styles/custom.scss +++ b/quartz-site/quartz/styles/custom.scss @@ -542,6 +542,123 @@ a:hover, line-height: 1.5; } +.gamecult-composite-page { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.gamecult-composite-overview > h2, +.gamecult-composite-overview > ul { + display: none; +} + +.gamecult-composite-jump { + display: flex; + flex-wrap: wrap; + gap: 0.65rem; + margin: 0; + padding: 0 0.15rem; +} + +.gamecult-composite-sections { + display: flex; + flex-direction: column; + gap: 1.45rem; +} + +.gamecult-composite-section { + position: relative; +} + +.gamecult-composite-divider { + height: 1.35rem; + margin: 0 1.2rem -0.25rem; + background: linear-gradient( + 90deg, + rgba(89, 183, 255, 0.1), + rgba(255, 138, 42, 0.28), + rgba(89, 183, 255, 0.08) + ); + clip-path: polygon(0 24%, 100% 0, 100% 74%, 70% 100%, 0 82%); + opacity: 0.75; +} + +.gamecult-composite-shell { + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: 1rem; + padding: 1.1rem; + border-radius: 24px; + border: 1px solid rgba(89, 183, 255, 0.14); + background: linear-gradient(180deg, rgba(8, 13, 21, 0.8), rgba(8, 13, 21, 0.72)); + box-shadow: + 0 22px 44px rgba(0, 0, 0, 0.2), + inset 0 1px 0 rgba(255, 255, 255, 0.03); + backdrop-filter: blur(14px); +} + +.gamecult-composite-shell.has-media { + grid-template-columns: minmax(280px, 0.88fr) minmax(0, 1.12fr); + align-items: stretch; +} + +.gamecult-composite-media { + min-width: 0; +} + +.gamecult-composite-media > figure { + height: 100%; + margin: 0; +} + +.gamecult-composite-media > figure img { + min-height: 280px; +} + +.gamecult-composite-copy { + min-width: 0; + padding: 0.15rem 0.1rem 0.2rem; +} + +.gamecult-composite-header { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + gap: 0.6rem 1.2rem; + align-items: baseline; + margin-bottom: 0.9rem; +} + +.gamecult-composite-header h2 { + margin: 0; + color: var(--dark); + font-family: var(--titleFont); + font-size: clamp(1.55rem, 2.4vw, 2.2rem); + font-weight: 200; + line-height: 0.96; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.gamecult-composite-standalone { + margin: 0; + font-family: var(--titleFont); + font-size: 0.76rem; + font-weight: 300; + letter-spacing: 0.1em; + text-transform: uppercase; +} + +.gamecult-composite-body > :first-child { + margin-top: 0; +} + +.gamecult-composite-body figure { + margin-left: 0; + margin-right: 0; +} + .gamecult-home-hero { display: grid; grid-template-columns: minmax(0, 1.15fr) minmax(280px, 0.85fr); @@ -656,6 +773,20 @@ a:hover, } @media all and ($mobile) { + .gamecult-composite-shell, + .gamecult-composite-shell.has-media { + grid-template-columns: minmax(0, 1fr); + padding: 0.95rem; + } + + .gamecult-composite-divider { + margin: 0 0.7rem -0.2rem; + } + + .gamecult-composite-header { + align-items: start; + } + .gamecult-hero-panel { padding: 1rem; }