1
0
mirror of https://github.com/SquidDev-CC/CC-Tweaked synced 2025-08-31 01:27:55 +00:00

Move website source/build logic to projects/web

Mostly useful as it moves some of our build logic out of the main
project, as that's already pretty noisy!
This commit is contained in:
Jonathan Coates
2022-11-06 13:37:07 +00:00
parent c82f37d3bf
commit d8e2161f15
23 changed files with 106 additions and 87 deletions

View File

@@ -0,0 +1,91 @@
plugins {
`lifecycle-base`
id("cc-tweaked.node")
id("cc-tweaked.illuaminate")
}
node {
projectRoot.set(rootProject.projectDir)
}
illuaminate {
version.set(libs.versions.illuaminate)
}
val rollup by tasks.registering(cc.tweaked.gradle.NpxExecToDir::class) {
group = LifecycleBasePlugin.BUILD_GROUP
description = "Bundles JS into rollup"
// Sources
inputs.files(fileTree("src")).withPropertyName("sources")
// Config files
inputs.file("tsconfig.json").withPropertyName("Typescript config")
inputs.file("rollup.config.js").withPropertyName("Rollup config")
// Output directory. Also defined in illuaminate.sexp and rollup.config.js
output.set(buildDir.resolve("rollup"))
args = listOf("rollup", "--config", "rollup.config.js")
}
val illuaminateDocs by tasks.registering(cc.tweaked.gradle.IlluaminateExecToDir::class) {
group = JavaBasePlugin.DOCUMENTATION_GROUP
description = "Generates docs using Illuaminate"
// Config files
inputs.file(rootProject.file("illuaminate.sexp")).withPropertyName("illuaminate config")
// Sources
inputs.files(fileTree("doc")).withPropertyName("docs")
inputs.files(fileTree("src/main/resources/data/computercraft/lua")).withPropertyName("lua rom")
inputs.files(rootProject.tasks.named("luaJavadoc"))
// Additional assets
inputs.files(rollup)
inputs.file("src/styles.css").withPropertyName("styles")
// Output directory. Also defined in illuaminate.sexp and transform.tsx
output.set(buildDir.resolve("illuaminate"))
args = listOf("doc-gen")
workingDir = rootProject.projectDir
}
val jsxDocs by tasks.registering(cc.tweaked.gradle.NpxExecToDir::class) {
group = JavaBasePlugin.DOCUMENTATION_GROUP
description = "Post-processes documentation to statically render some dynamic content."
// Config files
inputs.file("tsconfig.json").withPropertyName("Typescript config")
// Sources
inputs.files(fileTree("src")).withPropertyName("sources")
inputs.file(rootProject.file("src/generated/export/index.json")).withPropertyName("export")
inputs.files(illuaminateDocs)
// Output directory. Also defined in src/transform.tsx
output.set(buildDir.resolve("jsxDocs"))
args = listOf("ts-node", "-T", "--esm", "src/transform.tsx")
}
val docWebsite by tasks.registering(Copy::class) {
group = JavaBasePlugin.DOCUMENTATION_GROUP
description = "Assemble docs and assets together into the documentation website."
duplicatesStrategy = DuplicatesStrategy.FAIL
from(jsxDocs)
// Pick up assets from the /docs folder
from(rootProject.file("doc")) {
include("logo.png")
include("images/**")
}
// index.js is provided by illuaminate, but rollup outputs some other chunks
from(rollup) { exclude("index.js") }
// Grab illuaminate's assets. HTML files are provided by jsxDocs
from(illuaminateDocs) { exclude("**/*.html") }
// And item/block images from the data export
from(rootProject.file("src/generated/export/items")) { into("images/items") }
into(buildDir.resolve("site"))
}
tasks.assemble { dependsOn(docWebsite) }

View File

@@ -0,0 +1,58 @@
import { readFileSync } from "fs";
import path from "path";
import typescript from "@rollup/plugin-typescript";
import url from '@rollup/plugin-url';
import { terser } from "rollup-plugin-terser";
const input = "src";
const requirejs = readFileSync("../../node_modules/requirejs/require.js");
export default {
input: [`${input}/index.tsx`],
output: {
// Also defined in build.gradle.kts
dir: "build/rollup/",
// We bundle requirejs (and config) into the header. It's rather gross
// but also works reasonably well.
// Also suffix a ?v=${date} onto the end in the event we need to require a specific copy-cat version.
banner: `
${requirejs}
require.config({
paths: { copycat: "https://copy-cat.squiddev.cc" },
urlArgs: function(id) { return id == "copycat/embed" ? "?v=20211221" : ""; }
});
`,
format: "amd",
preferConst: true,
amd: {
define: "require",
}
},
context: "window",
external: ["copycat/embed"],
plugins: [
typescript(),
url({
include: "**/*.dfpwm",
fileName: "[name]-[hash][extname]",
publicPath: "/",
}),
{
name: "cc-tweaked",
async transform(code, file) {
// Allow loading files in /mount.
const ext = path.extname(file);
return ext != '.dfpwm' && path.dirname(file) === path.resolve(`${input}/mount`)
? `export default ${JSON.stringify(code)};\n`
: null;
},
},
terser(),
],
};

View File

@@ -0,0 +1,46 @@
import type { FunctionComponent } from "react";
import { createElement as h } from "react";
import useExport from "./WithExport.js";
const Item: FunctionComponent<{ item: string }> = ({ item }) => {
const data = useExport();
const itemName = data.itemNames[item];
return <img
src={`/images/items/${item.replace(":", "/")}.png`}
alt={itemName}
title={itemName}
className="recipe-icon"
/>
};
const EmptyItem: FunctionComponent = () => <span className="recipe-icon " />;
const Arrow: FunctionComponent<JSX.IntrinsicElements["svg"]> = (props) => <svg version="1.1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 45.513 45.512" {...props}>
<g>
<path d="M44.275,19.739L30.211,5.675c-0.909-0.909-2.275-1.18-3.463-0.687c-1.188,0.493-1.959,1.654-1.956,2.938l0.015,5.903
l-21.64,0.054C1.414,13.887-0.004,15.312,0,17.065l0.028,11.522c0.002,0.842,0.338,1.648,0.935,2.242s1.405,0.927,2.247,0.925
l21.64-0.054l0.014,5.899c0.004,1.286,0.781,2.442,1.971,2.931c1.189,0.487,2.557,0.21,3.46-0.703L44.29,25.694
C45.926,24.043,45.92,21.381,44.275,19.739z" fill="var(--recipe-hover)" />
</g>
</svg>;
const Recipe: FunctionComponent<{ recipe: string }> = ({ recipe }) => {
const data = useExport();
const recipeInfo = data.recipes[recipe];
if (!recipeInfo) throw Error("Cannot find recipe for " + recipe);
return <div className="recipe">
<strong className="recipe-title">{data.itemNames[recipeInfo.output]}</strong>
<div className="recipe-inputs">
{recipeInfo.inputs.map((items, i) => <div className="recipe-item recipe-input" key={i}>{items ? <Item item={items[0]} /> : <EmptyItem />}</div>)}
</div>
<Arrow className="recipe-arrow" />
<div className="recipe-item recipe-output">
<Item item={recipeInfo.output} />
{recipeInfo.count > 1 && <span className="recipe-count">{recipeInfo.count}</span>}
</div>
</div>
};
export default Recipe;

View File

@@ -0,0 +1,23 @@
import { createElement as h, useContext, createContext, FunctionComponent, ReactNode } from "react";
export type DataExport = {
readonly itemNames: Record<string, string>,
readonly recipes: Record<string, Recipe>,
};
export type Recipe = {
readonly inputs: Array<Array<string>>,
readonly output: string,
readonly count: number,
};
const DataExport = createContext<DataExport>({
itemNames: {},
recipes: {},
});
export const useExport = () => useContext(DataExport);
export default useExport;
export const WithExport: FunctionComponent<{ data: DataExport, children: ReactNode }> =
({ data, children }) => <DataExport.Provider value={data}> {children}</DataExport.Provider >;

View File

@@ -0,0 +1,25 @@
import type { FunctionComponent } from "react";
/**
* Wrap a component and ensure that no children are passed to it.
*
* Our custom tags *must* be explicitly closed, as <foo /> will be parsed as
* <foo>(rest of the document)</foo>. This ensures you've not forgotten to do
* that.
*
* @param component The component to wrap
* @returns A new functional component identical to the previous one
*/
export const noChildren = function <T>(component: FunctionComponent<T>): FunctionComponent<T> {
// I hope that our few remaining friends
// Give up on trying to save us
const name = component.displayName ?? component.name;
const wrapped: FunctionComponent<T> = props => {
if ((props as any).children) throw Error("Unexpected children in " + name);
return component(props);
};
wrapped.displayName = name;
return wrapped;
}

206
projects/web/src/index.tsx Normal file
View File

@@ -0,0 +1,206 @@
import { render, h, Component, Computer, PeripheralKind } from "copycat/embed";
import type { ComponentChild } from "preact";
import settingsFile from "./mount/.settings";
import startupFile from "./mount/startup.lua";
import exprTemplate from "./mount/expr_template.lua";
import exampleNfp from "./mount/example.nfp";
import exampleNft from "./mount/example.nft";
import exampleAudioLicense from "./mount/example.dfpwm.LICENSE";
import exampleAudioUrl from "./mount/example.dfpwm";
const defaultFiles: { [filename: string]: string } = {
".settings": settingsFile,
"startup.lua": startupFile,
"data/example.nfp": exampleNfp,
"data/example.nft": exampleNft,
};
const clamp = (value: number, min: number, max: number): number => {
if (value < min) return min;
if (value > max) return max;
return value;
}
const download = async (url: string): Promise<Uint8Array> => {
const result = await fetch(url);
if (result.status != 200) throw new Error(`${url} responded with ${result.status} ${result.statusText}`);
return new Uint8Array(await result.arrayBuffer());
};
let dfpwmAudio: Promise<Uint8Array> | null = null;
const Click = (options: { run: () => void }) =>
<button type="button" class="example-run" onClick={options.run}>Run </button>
type WindowProps = {};
type Example = {
files: { [file: string]: string | Uint8Array },
peripheral: PeripheralKind | null,
}
type WindowState = {
exampleIdx: number,
} & ({ visible: false, example: null } | { visible: true, example: Example })
type Touch = { clientX: number, clientY: number };
class Window extends Component<WindowProps, WindowState> {
private positioned: boolean = false;
private left: number = 0;
private top: number = 0;
private dragging?: { downX: number, downY: number, initialX: number, initialY: number };
private snippets: { [file: string]: string } = {};
constructor(props: WindowProps, context: unknown) {
super(props, context);
this.state = {
visible: false,
example: null,
exampleIdx: 0,
}
}
componentDidMount() {
const elements = document.querySelectorAll("pre[data-lua-kind]");
for (let i = 0; i < elements.length; i++) {
const element = elements[i] as HTMLElement;
let example = element.innerText;
const snippet = element.getAttribute("data-snippet");
if (snippet) this.snippets[snippet] = example;
// We attempt to pretty-print the result of a function _except_ when the function
// is print. This is pretty ugly, but prevents the confusing trailing "1".
if (element.getAttribute("data-lua-kind") == "expr" && !example.startsWith("print(")) {
example = exprTemplate.replace("__expr__", example);
}
const mount = element.getAttribute("data-mount");
const peripheral = element.getAttribute("data-peripheral");
render(<Click run={this.runExample(example, mount, peripheral)} />, element);
}
}
componentDidUpdate(_: WindowProps, { visible }: WindowState) {
if (!visible && this.state.visible) this.setPosition(this.left, this.top);
}
public render(_: WindowProps, { visible, example, exampleIdx }: WindowState): ComponentChild {
return visible ? <div class="example-window" style={`transform: translate(${this.left}px, ${this.top}px);`}>
<div class="titlebar">
<div class="titlebar-drag" onMouseDown={this.onMouseDown} onTouchStart={this.onTouchDown} />
<button type="button" class="titlebar-close" onClick={this.close}>{"\u2715"}</button>
</div>
<div class="computer-container">
<Computer key={exampleIdx} files={{
...defaultFiles, ...example!.files,
}} peripherals={{ back: example!.peripheral }} />
</div>
</div> : <div class="example-window example-window-hidden" />;
}
private runExample(example: string, mount: string | null, peripheral: string | null): () => void {
return async () => {
if (!this.positioned) {
this.positioned = true;
this.left = 20;
this.top = 20;
}
const files: { [file: string]: string | Uint8Array } = { "example.lua": example };
if (mount !== null) {
for (const toMount of mount.split(",")) {
const [name, path] = toMount.split(":", 2);
files[path] = this.snippets[name] || "";
}
}
if (example.includes("data/example.dfpwm")) {
files["data/example.dfpwm.LICENSE"] = exampleAudioLicense;
try {
if (dfpwmAudio === null) dfpwmAudio = download(exampleAudioUrl);
files["data/example.dfpwm"] = await dfpwmAudio;
} catch (e) {
console.error("Cannot download example dfpwm", e);
}
}
this.setState(({ exampleIdx }: WindowState) => ({
visible: true,
example: {
files,
peripheral: peripheral as PeripheralKind | null,
},
exampleIdx: exampleIdx + 1,
}));
}
}
private readonly close = () => this.setState({ visible: false });
// All the dragging code is terrible. However, I've had massive performance
// issues doing it other ways, so this'll have to do.
private onDown(e: Event, touch: Touch) {
e.stopPropagation();
e.preventDefault();
this.dragging = {
initialX: this.left, initialY: this.top,
downX: touch.clientX, downY: touch.clientY
};
window.addEventListener("mousemove", this.onMouseDrag, true);
window.addEventListener("touchmove", this.onTouchDrag, true);
window.addEventListener("mouseup", this.onUp, true);
window.addEventListener("touchend", this.onUp, true);
}
private readonly onMouseDown = (e: MouseEvent) => this.onDown(e, e);
private readonly onTouchDown = (e: TouchEvent) => this.onDown(e, e.touches[0]);
private onDrag(e: Event, touch: Touch) {
e.stopPropagation();
e.preventDefault();
const dragging = this.dragging;
if (!dragging) return;
this.setPosition(
dragging.initialX + (touch.clientX - dragging.downX),
dragging.initialY + (touch.clientY - dragging.downY),
);
};
private readonly onMouseDrag = (e: MouseEvent) => this.onDrag(e, e);
private readonly onTouchDrag = (e: TouchEvent) => this.onDrag(e, e.touches[0]);
private readonly onUp = (e: Event) => {
e.stopPropagation();
this.dragging = undefined;
window.removeEventListener("mousemove", this.onMouseDrag, true);
window.removeEventListener("touchmove", this.onTouchDrag, true);
window.removeEventListener("mouseup", this.onUp, true);
window.removeEventListener("touchend", this.onUp, true);
}
private readonly setPosition = (left: number, top: number): void => {
const root = this.base as HTMLElement;
left = this.left = clamp(left, 0, window.innerWidth - root.offsetWidth);
top = this.top = clamp(top, 0, window.innerHeight - root.offsetHeight);
root.style.transform = `translate(${left}px, ${top}px)`;
}
}
const root = document.createElement("div");
document.body.appendChild(root);
render(<Window />, document.body, root);

View File

@@ -0,0 +1,3 @@
{
[ "motd.enable" ] = false,
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,3 @@
Playing Soliloquy [Remake] by Alcakight
Source: https://soundcloud.com/alcaknight/soliloquy-remake
License: under CC BY 3.0

View File

@@ -0,0 +1,16 @@
fffffffffffffffffffffff
f444444444444444444444f
f444444444444444444444f
f44fffffffffffffffff44f
f44ff0ffffffffffffff44f
f44fff0fffffffffffff44f
f44ff0ffffffffffffff44f
f44fffffffffffffffff44f
f44fffffffffffffffff44f
f44fffffffffffffffff44f
f44fffffffffffffffff44f
f44fffffffffffffffff44f
f444444444444444444444f
f4444444444444444fff44f
f444444444444444444444f
fffffffffffffffffffffff

View File

@@ -0,0 +1,15 @@
4
4
4  4
0 4  4>0 ls 4
0 4  drom/ 4 0
0 4  startup.lua 4
0 4  4> 0hello 4
0 4  aHello World! 0 4
0 4  4
0 4  4
0 4  4
0 4
0 4  4
0 4

View File

@@ -0,0 +1,14 @@
local result = table.pack(__expr__
)
if result.n == 0 then return end
local pp = require "cc.pretty"
local line = {}
for i = 1, result.n do
if i > 1 then line[#line + 1] = pp.text(", ") end
line[#line + 1] = pp.pretty(result[i])
end
pp.print(pp.concat(table.unpack(line)))

View File

@@ -0,0 +1,14 @@
-- Print out license information if needed
if fs.exists("data/example.dfpwm") then
local h = io.open("data/example.dfpwm.LICENSE")
local contents = h:read("*a")
h:close()
write(contents)
end
-- Make the startup file invisible, then run the file. We could use
-- shell.run, but this ensures the program is in shell history, etc...
fs.delete("startup.lua")
os.queueEvent("paste", "example.lua")
os.queueEvent("key", keys.enter, false)

180
projects/web/src/styles.css Normal file
View File

@@ -0,0 +1,180 @@
:root {
--nav-width: 250px;
}
/* Some misc styles */
.big-image {
display: block;
margin: 0 auto;
max-height: 400px;
}
/* Pretty tables, mostly inherited from table.definition-list */
table {
border-collapse: collapse;
width: 100%;
}
table td,
table th {
border: 1px solid #cccccc;
padding: 2px 4px;
}
table th {
background-color: var(--background-2);
}
pre.highlight {
position: relative;
}
.example-run {
position: absolute;
top: 0;
right: 0;
background: #058e05;
color: #fff;
padding: 2px 5px;
}
.example-window {
position: fixed;
z-index: 200;
top: 0px;
top: 0px;
}
/* Behold, the most cursed CSS! copy-cat's resizing algorithm is a weird, in
that it basically does "wrapper height - 40px" to determine the effective
size. But the footer is actually 1em+6px high, so we need to do very weird
things.
Yes, it should probably be fixed on the copy-cat side.
*/
.computer-container {
width: 620px;
height: calc(350px + 40px);
margin-top: calc((1em + 6px - 40px) / 2);
}
.example-window-hidden {
display: none;
}
.titlebar {
display: flex;
background: #dede6c;
height: 30px;
}
.titlebar-drag {
flex-grow: 1;
cursor: grab;
}
.titlebar-close {
background: none;
padding: 0px 5px;
border: none;
border-radius: 0px;
margin: 0px;
font-size: 15px;
}
.titlebar-close:hover {
background: #cc4c4c;
}
@media (max-width: 700px) {
.computer-container {
width: 314px;
height: calc(179px + 40px);
}
.titlebar {
height: 20px;
}
.titlebar-close {
font-size: 7px;
}
}
:root {
--recipe-bg: #ddd;
--recipe-fg: #bbb;
--recipe-hover: #aaa;
--recipe-padding: 4px;
--recipe-size: 32px;
}
.recipe-container {
display: flex;
justify-content: center;
margin: 1em 0;
gap: 1em;
flex-wrap: wrap;
}
.recipe {
display: inline-grid;
padding: var(--recipe-padding);
background: var(--recipe-bg);
column-gap: var(--recipe-padding);
row-gap: var(--recipe-padding);
grid-template-rows: auto auto;
grid-template-columns: max-content 1fr max-content;
}
.recipe-title {
color: #222; /* Same as --foreground in light theme. Ugly, but too lazy to style in dark for now. */
grid-column-start: span 3;
}
.recipe-inputs {
display: inline-grid;
column-gap: var(--recipe-padding);
row-gap: var(--recipe-padding);
align-items: center;
grid-template-rows: 1fr 1fr 1fr;
grid-template-columns: 1fr 1fr 1fr;
}
.recipe-item {
padding: var(--recipe-padding);
background-color: var(--recipe-fg);
}
.recipe-item:hover {
background-color: var(--recipe-hover);
}
.recipe-icon {
display: block;
width: var(--recipe-size);
height: var(--recipe-size);
max-width: 10vw;
max-height: 10vw;
}
.recipe-arrow {
width: var(--recipe-size);
max-width: 10vw;
align-self: center;
}
.recipe-output {
align-self: center;
position: relative;
}
.recipe-count {
position: absolute;
bottom: 0;
right: var(--recipe-padding);
color: #fff;
text-shadow: -1px -1px 0 #000, 1px -1px 0 #000, -1px 1px 0 #000, 1px 1px 0 #000
}

View File

@@ -0,0 +1,53 @@
/**
* Find all HTML files generated by illuaminate and pipe them through a remark.
*
* This performs compile-time syntax highlighting and expands our custom
* components using React SSR.
*
* Yes, this would be so much nicer with next.js.
*/
import * as fs from "fs/promises";
import globModule from "glob";
import * as path from "path";
import { createElement, createElement as h, Fragment } from 'react';
import { renderToStaticMarkup } from "react-dom/server";
import rehypeHighlight from "rehype-highlight";
import rehypeParse from 'rehype-parse';
import rehypeReact from 'rehype-react';
import { unified } from 'unified';
import { promisify } from "util";
// Our components
import Recipe from "./components/Recipe.js";
import { noChildren } from "./components/support.js";
import { DataExport, WithExport } from "./components/WithExport.js";
const glob = promisify(globModule);
(async () => {
const base = "build/illuaminate";
const processor = unified()
.use(rehypeParse, { emitParseErrors: true })
.use(rehypeHighlight, { prefix: "" })
.use(rehypeReact, {
createElement,
Fragment,
passNode: false,
components: {
['mc-recipe']: noChildren(Recipe),
['mcrecipe']: noChildren(Recipe),
} as any
});
const dataExport = JSON.parse(await fs.readFile("../../src/generated/export/index.json", "utf-8")) as DataExport;
for (const file of await glob(base + "/**/*.html")) {
const contents = await fs.readFile(file, "utf-8");
const { result } = await processor.process(contents);
const outputPath = path.resolve("build/jsxDocs", path.relative(base, file));
await fs.mkdir(path.dirname(outputPath), { recursive: true });
await fs.writeFile(outputPath, "<!doctype HTML>" + renderToStaticMarkup(<WithExport data={dataExport}>{result}</WithExport>));
}
})();

View File

@@ -0,0 +1,61 @@
declare module "*.lua" {
const contents: string;
export default contents;
}
declare module "*.nfp" {
const contents: string;
export default contents;
}
declare module "*.nft" {
const contents: string;
export default contents;
}
declare module "*.settings" {
const contents: string;
export default contents;
}
declare module "*.LICENSE" {
const contents: string;
export default contents;
}
declare module "*.dfpwm" {
const contents: string;
export default contents;
}
declare module "copycat/embed" {
import { h, Component, render, ComponentChild } from "preact";
export type Side = "up" | "down" | "left" | "right" | "front" | "back";
export type PeripheralKind = "speaker";
export { h, Component, render };
export type ComputerAccess = unknown;
export type MainProps = {
hdFont?: boolean | string,
persistId?: number,
files?: { [filename: string]: string | ArrayBuffer },
label?: string,
width?: number,
height?: number,
resolve?: (computer: ComputerAccess) => void,
peripherals?: {
[side in Side]?: PeripheralKind | null
},
}
class Computer extends Component<MainProps, unknown> {
public render(props: MainProps, state: unknown): ComponentChild;
}
export { Computer };
}

View File

@@ -0,0 +1,32 @@
{
"compilerOptions": {
"module": "esNext",
"moduleResolution": "node",
"target": "es6",
"lib": [
"es2015",
"dom"
],
"newLine": "LF",
"baseUrl": ".",
// Additional compile options
"noEmitOnError": true,
"preserveWatchOutput": true,
"jsx": "react",
"jsxFactory": "h",
// Strict Type-Checking Options
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"importsNotUsedAsValues": "error",
"forceConsistentCasingInFileNames": true,
// Needed for some of our internal stuff.
"allowSyntheticDefaultImports": true,
},
"include": [
"src",
]
}