Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions examples/Racers offline.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
# Example configuration file for MakeCode Arcade to App to compile to static
# HTML, CSS, and JS files

# Config version (single number, incremented if breaking change to schema is
# made)
version: 1

project:
name: Racers
path_friendly_name: racers
description: "Enjoy the high-speed thrills of car racing in MakeCode Arcade!
For the MakeCode Arcade Mini Game Jam #3."
author: Cyrus Yiu
version: 1.3.2
# This is what the window title will be
# This will also be used for the executable file name for Electron outputs
# You can use {NAME} or {VERSION} or {AUTHOR} to substitute the correct
# values
title: "{NAME} v{VERSION}"

inputs:
# Can be share_link, github, or path
code:
type: path
value: "E:/Racers"

assets:
# Icon is optional but highly recommended, it will be used as the favicon
# and app icon
icon:
type: path
value: "E:/Racers/Racers icon.png"

# Path where the build process will take place
build_dir: "./examples/Racers-offline-build"

# The MakeCode Arcade version to target
# Must be explicit 3 number sem ver, do not add a "v" at the beginning
# To find it, go to https://arcade.makecode.com/ and click on the settings
# cogwheel button, click About..., and see "arcade version: x.y.z"
# As of the time of writing this, the latest version is 4.0.14
target: 2.0.48

# You can have multiple outputs
# Currently available options are static, static-singlefile, electron, and
# tauri
outputs:
- type: static
- type: static-singlefile
# - type: electron
# window:
# width: 640
# height: 480
# - type: tauri
# identifier: com.unsignedarduino.racers
# window:
# width: 640
# height: 480
7 changes: 7 additions & 0 deletions examples/Racers.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,13 @@ inputs:
# Path where the build process will take place
build_dir: "./examples/Racers-build"

# The MakeCode Arcade version to target
# Must be explicit 3 number sem ver, do not add a "v" at the beginning
# To find it, go to https://arcade.makecode.com/ and click on the settings
# cogwheel button, click About..., and see "arcade version: x.y.z"
# As of the time of writing this, the latest version is 4.0.14
target: 4.0.14

# You can have multiple outputs
# Currently available options are static, static-singlefile, electron, and
# tauri
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ dependencies = [
"requests",
"beautifulsoup4",
"pillow",
"platformdirs>=4.11.0",
]

[project.scripts]
Expand Down
56 changes: 10 additions & 46 deletions src/mkcd2app/build_project/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import logging
import shutil
from contextlib import ExitStack
from dataclasses import dataclass
from pathlib import Path
Expand All @@ -9,7 +8,7 @@

from mkcd2app.build_project.inputs.code import (
build_binary_js,
download_and_mod_supporting_files,
copy_support_files,
fetch_code,
)
from mkcd2app.build_project.website import (
Expand All @@ -19,42 +18,13 @@
install_deps_and_build_website_singlefile,
)
from mkcd2app.config import load_config_from_yaml
from mkcd2app.config.model import StaticOutput, StaticSinglefileOutput
from mkcd2app.models.config import StaticOutput, StaticSinglefileOutput
from mkcd2app.utils.logger import create_logger
from mkcd2app.utils.resources import get_js_tools_path, get_template_path
from mkcd2app.utils.run import run_cmd
from mkcd2app.utils.resources import get_resource_template_path

logger = create_logger(name=__name__, level=logging.INFO)


@task(namespace="mkcd2app")
def install_mkcd_build_tools(config_yaml: str, js_tools_src: ContentDir) -> ContentDir:
"""
Installs the MakeCode Arcade build tools.

:param config_yaml: The raw YAML text of the config file.
:param js_tools_src: A redun.ContentDir pointing to the js_tools directory,
so that redun tracks changes to package.json etc.
:return: A redun.ContentDir that points to node_modules, this is only used so that
redun will see that some tasks depend on `mkc` being installed.
"""
logger.info("Installing MakeCode Arcade build tools")

config = load_config_from_yaml(config_yaml)
build_path = Path(config.build_dir)
logger.debug(f"Tools will be installed in {build_path}")

js_tools_path = Path(js_tools_src.path)
shutil.copy(js_tools_path / "package.json", build_path / "package.json")
shutil.copy(js_tools_path / "package-lock.json", build_path / "package-lock.json")

run_cmd(["npm", "ci"], cwd=build_path)

logger.debug("All MakeCode Arcade build tools installed")

return ContentDir(str(build_path / "node_modules"))


@dataclass
class BuildProjectResult:
static: ContentDir | None = None
Expand All @@ -79,22 +49,15 @@ def build_project(config_yaml: str) -> BuildProjectResult:
build_dir.mkdir(parents=True, exist_ok=True)

with ExitStack() as stack:
js_tools_path = stack.enter_context(get_js_tools_path())
js_tools_content = ContentDir(str(js_tools_path))

template_path = stack.enter_context(get_template_path("vite-project"))
template_content = ContentDir(str(template_path))
template_path = stack.enter_context(get_resource_template_path("vite-project"))
template_content = ContentDir(str(template_path / "vite-project"))

# Install `mkc` with `npm ci` in build dir
node_modules_for_mkc = install_mkcd_build_tools(config_yaml, js_tools_content)
# Fetch game source code with `mkc`, `git`, or copy from disk
code_path = fetch_code(config_yaml, node_modules_for_mkc)
code_path = fetch_code(config_yaml)
# Build binary.js with `mkc`
bin_js_path = build_binary_js(config_yaml, code_path)
# Download supporting files to run binary.js, including ---simulator.html and all
# it's references, and get favicon.ico if present
support_path = download_and_mod_supporting_files(config_yaml)

# Copy ---simulator.html from target dir and get favicon.ico if present
support_path = copy_support_files(config_yaml)
# Copy website template (clean copy with template files only)
website_path = copy_website_template(config_yaml, template_content)
# Copy + fill (separate dir so stages don't mutate each other's
Expand All @@ -104,7 +67,8 @@ def build_project(config_yaml: str) -> BuildProjectResult:
)

results = BuildProjectResult()

# Build the website outputs
# Electron and Tauri outputs depend on static_singlefile so redun figures it out
logger.debug(f"{config.outputs=}")
for output in config.outputs:
match output.root:
Expand Down
89 changes: 35 additions & 54 deletions src/mkcd2app/build_project/inputs/code.py
Original file line number Diff line number Diff line change
@@ -1,40 +1,39 @@
import json
import logging
import shutil
from io import BytesIO
from pathlib import Path

import requests
from bs4 import BeautifulSoup
from PIL import Image
from redun import task
from redun.file import ContentDir, ContentFile

from mkcd2app.config import load_config_from_yaml
from mkcd2app.config.model import (
from mkcd2app.models.config import (
GitHubCodeSource,
PathAssetSource,
PathCodeSource,
ShareLinkCodeSource,
UrlAssetSource,
)
from mkcd2app.utils.filesystem import rmtree_robust
from mkcd2app.utils.logger import create_logger
from mkcd2app.utils.paths import rmtree_robust
from mkcd2app.utils.paths import get_js_tools_bin_dir, get_sim_html_path
from mkcd2app.utils.run import run_cmd

logger = create_logger(name=__name__, level=logging.INFO)


@task(namespace="mkcd2app")
def fetch_code(config_yaml: str, node_modules_for_mkc: ContentDir) -> ContentDir:
def fetch_code(config_yaml: str) -> ContentDir:
"""
Download/clone/copy the source code to the build directory.

Example input: config_yaml, ./node_modules
Example output: ./racers-source

:param config_yaml: The raw YAML text of the config file.
:param node_modules_for_mkc: redun.ContentDir that points to the node_modules
directory, this ensures that this task depends on `mkc` being installed.
:return: A redun.ContentDir that points to the source code.
"""
config = load_config_from_yaml(config_yaml)
Expand All @@ -48,10 +47,13 @@ def fetch_code(config_yaml: str, node_modules_for_mkc: ContentDir) -> ContentDir

match config.inputs.code.root:
case ShareLinkCodeSource(value=url):
logger.debug(f"Downloading source code from {url}")
logger.debug(f"Downloading source code from {url} with `mkc` CLI")
code_path.mkdir(parents=True)
logger.debug(f"Using `mkc` from {node_modules_for_mkc}")
run_cmd(["npx", "mkc", "download", str(url)], cwd=code_path)
run_cmd(
["mkc", "download", str(url)],
cwd=code_path,
which_path=get_js_tools_bin_dir(),
)
case GitHubCodeSource(value=url, checkout=checkout_target):
logger.debug(f"Cloning source code from {url}@{checkout_target}")
abs_code_path = code_path.resolve()
Expand All @@ -72,6 +74,12 @@ def fetch_code(config_yaml: str, node_modules_for_mkc: ContentDir) -> ContentDir
logger.debug(f"Copying source code from {path}")
shutil.copytree(path, code_path)

mkc_json_path = code_path / "mkc.json"
logger.debug("Writing mkc.json")
version = config.target
mkc_json = {"targetWebsite": f"https://arcade.makecode.com/v{version}"}
mkc_json_path.write_text(json.dumps(mkc_json))

logger.debug("Source code downloaded")
return ContentDir(str(code_path))

Expand All @@ -94,7 +102,11 @@ def build_binary_js(config_yaml: str, code_path: ContentDir) -> ContentFile:

cwd = Path(code_path.path)
logger.debug(f"Building in cwd {cwd}")
run_cmd(["npx", "mkc", "build", "-j"], cwd=cwd)
run_cmd(
["mkc", "build", "-j"],
cwd=cwd,
which_path=get_js_tools_bin_dir(),
)

bin_js_path = cwd / "built" / "binary.js"
logger.debug(f"binary.js available at {bin_js_path}")
Expand All @@ -112,7 +124,7 @@ def build_binary_js(config_yaml: str, code_path: ContentDir) -> ContentFile:


@task(namespace="mkcd2app")
def download_and_mod_supporting_files(config_yaml: str) -> ContentDir:
def copy_support_files(config_yaml: str) -> ContentDir:
"""
Download and modify all supporting files needed to run binary.js for the website

Expand All @@ -128,60 +140,29 @@ def download_and_mod_supporting_files(config_yaml: str) -> ContentDir:
Path(config.build_dir)
/ f"{config.project.path_friendly_name}-binary-js-support"
)
logger.info(f"Downloading supporting files to {support_path}")
target = config.target
logger.info(
f"Copying ---simulator.html for MakeCode Arcade {target} to {support_path}"
)

# Clean previous output, not wasteful because redun handles caching
if support_path.exists():
shutil.rmtree(support_path)
support_path.mkdir(parents=True)

logger.debug("Downloading main simulator file")
res = requests.get("https://trg-arcade.userpxt.io/---simulator")
res.raise_for_status()
sim_html = res.text

logger.debug(
f"Analyzing sim HTML ({len(sim_html)} chars) for required CSS and JS files"
)
soup = BeautifulSoup(sim_html, features="html.parser")
css_links = soup.find_all("link", rel="stylesheet")
js_scripts = soup.find_all("script")
logger.debug(f"Found {len(css_links)} CSS links and {len(js_scripts)} JS scripts")
for css in css_links:
url = css.get("href")
if url:
logger.debug(f"Downloading CSS file {url}")
res = requests.get(str(url))
res.raise_for_status()
style_tag = soup.new_tag("style")
style_tag.string = res.text
css.replace_with(style_tag)
logger.debug(f"Inlined CSS from {url}")
for js in js_scripts:
url = js.get("src")
if url:
logger.debug(f"Downloading JS file {url}")
res = requests.get(str(url))
res.raise_for_status()
js.string = res.text
del js["src"]
logger.debug(f"Inlined JS from {url}")
new_sim_html = soup.prettify(formatter="html5")
path = support_path / "---simulator.html"
path.write_text(new_sim_html)
logger.debug(f"Wrote modified simulator HTML to {path}")
# Only one file to copy
shutil.copy(get_sim_html_path(target), support_path)

if config.inputs.assets.icon:
match config.inputs.assets.icon.root:
case UrlAssetSource(value=url): # type: ignore[misc]
logger.debug(f"Downloading icon from {url}")
res = requests.get(str(url))
case UrlAssetSource(value=icon_url):
logger.debug(f"Downloading icon from {icon_url}")
res = requests.get(str(icon_url))
res.raise_for_status()
buffer = BytesIO(res.content)
im = Image.open(buffer)
case PathAssetSource(value=path): # type: ignore[misc]
logger.debug(f"Opening icon from {path}")
im = Image.open(path)
case PathAssetSource(value=icon_path):
logger.debug(f"Opening icon from {icon_path}")
im = Image.open(icon_path)
favicon_path = support_path / "favicon.ico"
logger.debug(f"Saving favicon to {favicon_path}")
im.save(favicon_path)
Expand Down
11 changes: 9 additions & 2 deletions src/mkcd2app/build_project/website.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

from mkcd2app.config import load_config_from_yaml
from mkcd2app.utils.logger import create_logger
from mkcd2app.utils.paths import get_templates_npm_cache_dir
from mkcd2app.utils.run import run_cmd

logger = create_logger(name=__name__, level=logging.INFO)
Expand Down Expand Up @@ -143,7 +144,10 @@ def install_deps_and_build_website(website_filled_path: ContentDir) -> ContentDi
shutil.rmtree(dst)
shutil.copytree(src, dst)

run_cmd(["npm", "ci"], cwd=dst)
cache_path = get_templates_npm_cache_dir()
logger.debug(f"Using template npm cache at {cache_path} to install")

run_cmd(["npm", "ci", "--cache", str(cache_path), "--offline"], cwd=dst)
logger.debug("Website dependencies installed")

run_cmd(["npm", "run", "build"], cwd=dst)
Expand Down Expand Up @@ -180,7 +184,10 @@ def install_deps_and_build_website_singlefile(
shutil.rmtree(dst)
shutil.copytree(src, dst)

run_cmd(["npm", "ci"], cwd=dst)
cache_path = get_templates_npm_cache_dir()
logger.debug(f"Using template npm cache at {cache_path} to install")

run_cmd(["npm", "ci", "--cache", str(cache_path), "--offline"], cwd=dst)
logger.debug("Website dependencies installed")

run_cmd(["npm", "run", "build:singlefile"], cwd=dst)
Expand Down
Loading