🐛 Fixed building and cleaned up a little

main
Basil 2 months ago
parent 40b058c16e
commit 7510868799

@ -1,10 +0,0 @@
BasedOnStyle: Google
AlignAfterOpenBracket: AlwaysBreak
AllowAllParametersOfDeclarationOnNextLine: false
AllowShortBlocksOnASingleLine: false
AllowShortCaseLabelsOnASingleLine: false
AllowShortFunctionsOnASingleLine: None
AllowShortIfStatementsOnASingleLine: false
AllowShortLoopsOnASingleLine: false
BinPackArguments: false
ColumnLimit: 100

1
.gitignore vendored

@ -2,3 +2,4 @@ dist/
node_modules/
.DS_Store
.vscode/
notes.md

@ -1,154 +1,5 @@
# Google Santa Tracker for Web
# Penguin Dash Enhanced
This repository contains the code to [Google Santa Tracker](https://santatracker.google.com), an educational and entertaining tradition for the December holiday period.
This repository contains the code to the game Penguin Dash from [Google's Santa Tracker](https://santatracker.google.com), migraterd from Closure Compiler to Vite, and with various tweaks that aim to improve playing the game more competitively.
We hope you find this source code interesting.
In general, we do not accept external contributions from the public.
You can file bug reports or feature requests, or contact the engineering lead [Jez Swanson](https://twitter.com/jezzamonn).
(This text duplicated in [contributing.md](docs/contributing.md))
## Supports
Santa Tracker supports evergreen versions of Chrome, Firefox and Safari.
It also supports other Chromium-based browsers (Edge, Opera etc).
We also present a "fallback mode" for older browsers, such as IE11, which allow users to play a small number of historic games.
# Site Structure
Santa Tracker is split up into different scenes. Each page on the Santa Tracker corresponds to one scene, including the main village page, [modvil](static/scenes/modvil/index.html). The scenes are in the [static/scenes/](static/scenes/) directory. Each scene is loaded as an iframe, and is relatively self contained.
The host part of the site handles the loading of each scene, as well as the music and common UI, like the game score or tutorial. There's an [API](static/src/scene/api.js) between the host and the scenes, which allows the host to notify the scenes when events like the scene loading happens, and allows the scenes to tell the host to do things like play a song or update the score.
# Development Guide
## Running locally
You'll need `yarn` or `npm`.
You may also need Java if you're building on Windows, as the binary version of Closure Compiler is unsupported on that platform.
Clone and run `yarn` or `npm install` to install deps, and run `./serve.js` to run a development server.
The development URL will be copied to your clipboard.
The serving script `./serve.js` will listen on both ports 8000 and 8080 by default. Port 8000 serves the host part of the site (this corresponds to the production https://santatracker.google.com domain), and port 8080 serves the static content, including the scenes.
If you have Nix installed, you can run `nix-shell -p jre8`, followed by `node serve.js`.
To load a specific scene, open e.g., http://localhost:8000/boatload.html.
Once the site is loaded, you can also run `santaApp.route = 'sceneName'` in the console to switch scenes programmatically.
If you'd like to load a scene from the static domain—without the "host" code—you can load it at e.g., http://127.0.0.1:8080/st/scenes/elfmaker/.
This is intentionally not equal to "localhost" so that prod and static run cross-domain.
The "host" provides scores, audio and some UI, so not all behavior is available in this mode.
As of 2020, development requires Chrome or a Chromium-based browser.
This is due to the way we identify ESM import requests, where Chromium specifies additional headers.
(This is a bug, not a feature.)
## Add A New Scene
Scenes are fundamentally just pages loaded in an `<iframe>`.
You can write them in any way you like, but be sure to call out to the "host" to play audio, report scores, or request other things like the display of tutorials.
To add a new scene, you'll need to:
* Create the `static/scenes/sceneName` folder, adding `index.html`, which runs code in ES modules only:
1. Ensure you include a `<script type="module">` that imports `src/scene/api.js`, which sets up the connection to the prod "host".
2. Optionally listen to events from the API, such as 'pause', 'resume', and 'restart'; and configure an `api.ready(() => { ... })` callback that is triggered when the scene is to be swapped in
3. Import the magic URL `./:closure.js` if you're writing Closure-style code―this will compile everything under `js/`
4. For more information, see an existing scene like [boatload](static/scenes/boatload/index.html) or [santaselfie](static/scenes/santaselfie/index.html)
* Add associated PNGs:
* `static/img/scenes/sceneName_2x.png` (950x564) and `sceneName_1x.png` (475x282)
* `prod/images/og/sceneName.png` (1333x1000)
* Name the scene inside [strings](static/src/strings/scenes.js).
* If your scene should not be released to production, disable it inside [release.js](release.js).
## Environment
The build system provides a virtual file system that automatically compiles various source types useful for development and provides a number of helpers.
This includes:
* `.css` files are generated for their corresponding `.scss`
* `.json` is generated for their corresponding `.json5`
* The `static/scenes/sceneName/:closure.js` file can be read to compile an older scene's `js/` folder with Closure Compiler, providing a JS module with default export.
These files don't actually exist, but are automatically created on use.
For example, if `foo.scss` exists, you can simply load `foo.css` to compile it automatically.
### Sass helpers
When writing SCSS, the helper `_rel(path.png)` generates a `url()` which points to a file _relative_ to the current `.scss` source file—even imports.
This works regardless of how the SCSS is finally used, whether `<link href="..." />` or as part of a Web Component.
### JavaScript
The source file `static/src/magic.js` provides various template tag helpers which, while real functions, are inlined at release time.
These include:
* ``_msg`msgid_here``` generates the corresponding i18n string
* ``_static`path_name``` generates an absolute reference to a file within `static`
Also, Santa Tracker is built using JS modules and will rewrite non-relative imports for `node_modules`.
For example, if you `import {LitElement} from 'lit-element';`, this will be rewritten to its full path for development or release.
### Imports
As well as JavaScript itself, Santa Tracker's development environment allows imports of future module types: CSS, JSON and HTML.
## Input
When possible support touch, keyboard, and gamepad input. Note that basic gamepad
support is offered via synthetic keyboard events in [keys.js](static/src/core/keys.js).
## Sound
Santa Tracker uses an audio library known which exists in the prod "host" only, but can be triggered by API calls in scenes.
This is largely undocumented and provided by an external vendor.
If you're interested in the audio source files, they are in the repo under `static/audio` (and are licensed, as mentioned below, as CC-BY).
The audio library plays audio triggers which play temporary sounds (e.g., a button click) or loops (audio tracks).
Scenes can be configured with audio triggers to start with (via `api.config({sound: [...]})`) which will cause all previous audio to stop, good for shutting down previous games.
## Translations
Santa Tracker contains translations for a variety of different languages.
These translations are sourced from Google's internal translation tool.
If you're adding a string for development, please modify `en_src_messages.json` and ask a Google employee to request a translation run.
If you'd building Santa Tracker for production, you'll need the string to be translated and the final output contained within `lang/`.
## Production
While the source code includes a release script, it's not intended for end-users to run and is used by Googlers to deploy the site.
# Historic Versions
The previous version of Santa Tracker, used until 2018, is available in the [archive-2018](https://github.com/google/santa-tracker-web/tree/archive-2018) branch.
# License
All image and audio files (including *.png, *.jpg, *.svg, *.mp3, *.wav
and *.ogg) are licensed under the CC-BY license. All other files are
licensed under the Apache 2 license. See the LICENSE file for details.
Copyright 2020 Google LLC
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
There's probably still a lot of dead code lying around.

@ -1,70 +1,24 @@
{
"name": "santatracker",
"version": "2021.1.0",
"license": "Apache-2.0",
"scripts": {
"vite": "vite --port 3000",
"build": "vite build && esbuild static/scenes/penguindash/phaser-arcade-physics.js --minify --outfile=dist/static/scenes/phaser-arcade-physics.js",
"dev": "npm run start",
"start": "./serve.js",
"release": "./release.js",
"test": "mocha-headless-server src/api/test.html",
"staging-check": "node .cloudbuild/staging-check.js",
"postinstall": "ln -s ../node_modules static/node_modules || true"
},
"dependencies": {
"@babel/core": "^7.6.0",
"@babel/preset-env": "^7.6.0",
"@rollup/plugin-commonjs": "^21.0.1",
"@rollup/plugin-node-resolve": "^13.0.6",
"@webcomponents/webcomponentsjs": "^2.4.0",
"autoprefixer": "^9.3.0",
"babel-plugin-func-wrap": "^1.1.0",
"chai": "^4.2.0",
"chalk": "^2.4.2",
"clipboardy": "^2.1.0",
"core-js": "^3.3.2",
"custom-event-polyfill": "^1.0.7",
"dat.gui": "^0.7.3",
"dhost": "^0.3.5",
"esm-resolve": "^1.0.6",
"event-target": "^1.2.3",
"fancy-log": "^1.3.2",
"fast-async": "^6.3.8",
"firebase": "^8.10.0",
"git-last-commit": "^1.0.1",
"google-closure-compiler": "^20190909.0.0",
"google-closure-library": "^20190909.0.0",
"html-entities": "^1.2.1",
"html-minifier": "^4.0.0",
"html-modules-polyfill": "^0.1.0",
"iframe-load": "^0.1.4",
"jquery": "^3.5.0",
"jsdom": "^12.2.0",
"json5": "^2.1.0",
"lit-element": "^2.2.1",
"lottie-web": "^5.5.10",
"mime-types": "^2.1.21",
"mocha": "^5.2.0",
"mocha-headless-server": "^0.1.2",
"parse5": "^5.1.0",
"phaser": "2.6.2",
"polka": "^0.5.2",
"pretty-ms": "^4.0.0",
"regenerator-runtime": "^0.13.3",
"rimraf": "^3.0.2",
"rollup": "^2.59.0",
"sass": "^1.22.9",
"terser": "^3.10.11",
"tmp": "^0.0.33",
"unistore": "^3.4.1",
"web-animations-js": "^2.3.1",
"whatwg-fetch": "^3.0.0",
"yargs": "^12.0.2"
},
"devDependencies": {
"@google-cloud/cloudbuild": "^2.6.0",
"@google-cloud/error-reporting": "^2.0.4",
"vite": "^4.0.1"
}
"name": "santatracker",
"version": "2021.1.0",
"license": "Apache-2.0",
"scripts": {
"vite": "vite --port 3000",
"build": "vite build && esbuild static/scenes/penguindash/phaser-arcade-physics.js --minify --outfile=dist/static/scenes/phaser-arcade-physics.js",
"dev": "vite dev"
},
"dependencies": {
"custom-event-polyfill": "^1.0.7",
"dat.gui": "^0.7.3",
"event-target": "^1.2.3",
"iframe-load": "^0.1.4",
"jquery": "^3.5.0",
"lit-element": "^2.2.1",
"lottie-web": "^5.5.10",
"sass": "^1.22.9",
"unistore": "^3.4.1"
},
"devDependencies": {
"vite": "^4.0.1"
}
}

File diff suppressed because one or more lines are too long

@ -1,748 +0,0 @@
#!/usr/bin/env node
/**
* Copyright 2020 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* @fileoverview Builds Santa Tracker for release to production.
*/
const babel = require('@babel/core');
const generator = require('@babel/generator');
const traverse = require('@babel/traverse');
const chalk = require('chalk');
const fsp = require('./build/fsp.js');
const globAll = require('./build/glob-all.js');
const i18n = require('./build/i18n.js');
const importUtils = require('./build/import-utils.js');
const log = require('fancy-log');
const path = require('path');
const releaseHtml = require('./build/release-html.js');
const santaVfs = require('./santa-vfs.js');
const modernBuilder = require('./build/modern-builder.js');
const sourceMagic = require('./build/source-magic.js');
const {Writer} = require('./build/writer.js');
const JSON5 = require('json5');
const {Worker} = require('worker_threads');
const WorkGroup = require('./build/group.js');
const { getCurrentVersion } = require('./build/git-version.js');
const DISABLED_SCENES = 'poseboogie languagematch'.split(/\s+/);
// Generates a version like `vYYYYMMDDHHMM`, in UTC time.
const DEFAULT_STATIC_VERSION = 'v' + (new Date).toISOString().replace(/[^\d]/g, '').substr(0, 12);
const DEFAULT_LANG = 'en'; // write these files to top-level
const yargs = require('yargs')
.strict()
.epilogue('https://github.com/google/santa-tracker-web')
.option('build', {
alias: 'b',
type: 'string',
default: DEFAULT_STATIC_VERSION,
describe: 'Production build tag',
})
.options('transpile', {
type: 'boolean',
default: true,
describe: 'Transpile for ES5 browsers (slow)',
})
.options('minify', {
type: 'boolean',
default: true,
describe: 'Minify JavaScript output',
})
.option('default-only', {
alias: 'o',
type: 'boolean',
default: false,
describe: 'Only generate top-level language',
})
.option('baseurl', {
type: 'string',
default: 'https://maps.gstatic.com/mapfiles/santatracker/',
describe: 'URL to static content',
coerce: (raw) => {
// Ensures that the passed baseurl ends with '/', or if not passed an actual URL, that it
// starts with '/'.
if (!raw.endsWith('/')) {
raw = `${raw}/`;
}
try {
new URL(raw);
return raw;
} catch (e) {
// ignore
}
return importUtils.pathname(raw); // ensures this has a leading '/'
},
})
.option('prod', {
type: 'boolean',
default: true,
describe: 'Whether to build prod and related static entrypoint',
})
.option('scene', {
type: 'array',
default: [],
describe: 'Limit static build to selected scenes',
})
.argv;
// These assets are copied verbatim into the dist static/prod folders. Note the ! exclusions below.
const assetsToCopy = [
'static/audio/*',
'static/fallback-audio/*',
'static/img/**/*',
'static/third_party/**/LICENSE*',
'static/third_party/lib/klang/**',
'static/scenes/**/models/**',
'static/scenes/**/img/**',
// Explicitly include Web Components loader and polyfill bundles, as they're injected at runtime
// rather than being directly referenced by a `<script>`.
'static/node_modules/@webcomponents/webcomponentsjs/bundles/*.js',
'static/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js',
'static/node_modules/@webcomponents/webcomponentsjs/webcomponents-loader.js',
'prod/**',
'!prod/**/*.html',
'!prod/**/*.js',
'!prod/**/*.json',
];
// nb. matches config in serve.js
const config = {
staticScope: importUtils.joinDir(yargs.baseurl, yargs.build),
version: yargs.build,
baseurl: yargs.baseurl,
};
const vfs = santaVfs(config.staticScope, {config});
const releaseWriter = new Writer({
loader: vfs,
allowed: ['static', 'prod'],
target: 'dist',
});
const workGroup = WorkGroup();
/**
* Terser is slow and CPU-bound, so push it out to multiple cores.
*
* @param {string} code to minify
* @return {Promise<string>}
*/
async function optionalMinify(code) {
if (!yargs.minify) {
return code;
}
await workGroup(async () => {
const w = new Worker(__dirname + '/build/terser-worker.js', {
workerData: code,
});
const result = await new Promise((resolve, reject) => {
w.on('message', resolve);
w.on('error', reject);
w.on('exit', reject);
});
w.unref();
w.terminate();
if (result.error) {
throw new TypeError(`Terser error on ${fileName}: ${result.error}`);
}
code = result.code;
});
return code;
}
function prodPathForLang(lang) {
if (lang === DEFAULT_LANG) {
return 'prod';
}
return `prod/intl/${lang}_ALL`
}
function rewritePathForLang(id, lang) {
if (!lang) {
return id;
}
const p = path.parse(id);
return path.join(p.dir, `${p.name}_${lang}${p.ext}`);
}
/**
* @return {!Array<string>} all entrypoint HTML files to be built for this release
*/
async function findStaticHtml() {
let htmlFiles;
if (yargs.scene.length) {
const globArg = yargs.scene.map((scene) => path.join('static/scenes', scene, '**/index.html'));
htmlFiles = globAll(...globArg);
} else {
htmlFiles = globAll('static/**/index.html');
}
htmlFiles = htmlFiles.filter((raw) => {
const sceneMatch = raw.match(/^static\/scenes\/(.*?)\//);
return !(sceneMatch && DISABLED_SCENES.includes(sceneMatch[1]));
});
return htmlFiles;
}
async function release() {
log(`Building Santa Tracker ${chalk.red(yargs.build)}...`);
log(`Platform: ${chalk.red(process.platform)}`);
const gitRevision = await getCurrentVersion();
log(`git revision: ${chalk.red(gitRevision)}`);
if (await fsp.exists('dist')) {
log(`Removing previous release...`);
await fsp.unlinkAll('dist');
}
// Create both "static" and "prod" targets inside dist. This lets us build the site (which, at
// the top-level, is in "static" and "prod" dirs) and just deploy to those matching dirs.
const staticRoot = importUtils.pathname(config.staticScope);
if (importUtils.isUrl(config.staticScope)) {
// In normal operation (baseurl is on a unique domain), "_static" contains the actual static
// root with its entire path. Create a symlink for "static" which points there.
await fsp.mkdirp(path.join('dist/_static', staticRoot));
await fsp.symlink(path.join('_static', staticRoot), 'dist/static');
} else {
// If hosting on one domain, the "static" symlink points within "prod", as there's just one
// directory of content to serve.
await fsp.mkdirp(path.join('dist/prod', staticRoot));
await fsp.symlink(path.join('prod', staticRoot), 'dist/static');
}
await fsp.mkdirp('dist/prod');
// Display the static URL plus the root (in a different color).
if (!config.staticScope.endsWith(staticRoot)) {
throw new TypeError(`invalid static resolution: ${config.staticScope} vs ${staticRoot}`)
}
const domainNotice = importUtils.isUrl(config.staticScope) ? '' : '(local)';
log('Static at', chalk.green(config.staticScope), domainNotice);
// Find the list of languages by reading `_messages`.
const missingMessages = {};
const langs = i18n.all((lang, msgid) => {
if (!(msgid in missingMessages)) {
missingMessages[msgid] = new Set();
}
missingMessages[msgid].add(lang);
});
if (!(DEFAULT_LANG in langs)) {
throw new Error(`default lang '${DEFAULT_LANG}' not found in _messages`);
}
if (yargs.defaultOnly) {
Object.keys(langs).forEach((otherLang) => {
if (otherLang !== DEFAULT_LANG) {
delete langs[otherLang];
}
});
}
log(`Building ${chalk.cyan(Object.keys(langs).length)} languages`);
// Release prod entrypoints.
if (yargs.prod) {
await releaseProd(langs);
}
// Shared resources needed by prod build.
const staticEntrypoints = {};
const fallbackEntrypoints = {};
const requiredExternalSources = new Set();
// Santa Tracker builds static by finding HTML entry points and parsing/rewriting each file,
// including traversing their dependencies like CSS and JS. It doesn't specifically compile CSS
// or JS on its own, it must be included by one of our HTML entry points.
const htmlFiles = await findStaticHtml();
if (!htmlFiles.length) {
throw new Error('No static entrypoints matched (bad --scene?)');
}
log(`Processing ${chalk.cyan(htmlFiles.length)} entrypoint HTML files...`);
const htmlDocuments = new Map();
for (const htmlFile of htmlFiles) {
const dom = await releaseHtml.dom(htmlFile);
const document = dom.window.document;
const dir = path.dirname(htmlFile);
// Find assets that are going to be inlined.
const styleLinks = [...document.querySelectorAll('link[rel="stylesheet"]')];
const allScripts = Array.from(document.querySelectorAll('script')).filter((scriptNode) => {
return !(scriptNode.src && importUtils.isUrl(scriptNode.src));
});
// Inline all local referenced styles.
for (const styleLink of styleLinks) {
if (importUtils.isUrl(styleLink.href)) {
continue; // TODO(samthor): mostly Google Fonts, but could be worth validating
}
const target = path.join(dir, styleLink.href);
const out = await vfs(target) || await fsp.readFile(target, 'utf-8');
const inlineStyleTag = document.createElement('style');
inlineStyleTag.innerHTML = typeof out === 'string' ? out : ('code' in out ? out.code : out);
styleLink.replaceWith(inlineStyleTag);
}
// Find non-module scripts, as they contain dependencies like jQuery, THREE.js etc. These are
// catalogued and then included in the static output.
const external = allScripts
.filter((s) => s.src && (!s.type || s.type === 'text/javascript')).map((s) => s.src);
external.push(...[...document.querySelectorAll('link[rel="preload"]')].map((s) => s.href));
external.forEach((src) => {
// ... only add if they're local
if (!importUtils.isUrl(src)) {
requiredExternalSources.add(path.join(dir, src));
}
});
// Create knowledge of all imports for this HTML file. Remove all module scripts.
let imports = 0;
const moduleScriptNodes = allScripts.filter((s) => s.type === 'module');
for (const scriptNode of moduleScriptNodes) {
let code = scriptNode.textContent;
if (scriptNode.src) {
if (code) {
throw new TypeError(`got invalid <script>: both code and src`);
}
code = importUtils.staticImport(scriptNode.src);
}
scriptNode.remove();
// Either way, this creates a virtual import: if the <script> contained code, it's just that;
// otherwise, it imports the external file.
staticEntrypoints[`${dir}/${imports}.js`] = code;
if (yargs.transpile) {
fallbackEntrypoints[`${dir}/fallback-${imports}.js`] = code;
}
++imports;
}
htmlDocuments.set(htmlFile, {dom, imports, dir});
}
// Optionally include entrypoints (needed for prod).
if (yargs.prod) {
staticEntrypoints['static/entrypoint.js'] = undefined;
staticEntrypoints['prod/sw.js'] = undefined;
if (yargs.transpile) {
fallbackEntrypoints['static/fallback.js'] = undefined;
}
// This isn't guarded by a transpile check, because we want always want to transpile it anyway.
fallbackEntrypoints['prod/loader.js'] = undefined;
}
log(`Found ${chalk.cyan(requiredExternalSources.size)} required external sources`);
log(`Found ${chalk.cyan(Object.keys(staticEntrypoints).length)} modern entrypoints (${chalk.cyan(Object.keys(fallbackEntrypoints).length)} support/loader), merging...`);
const builderOptions = {
loader: vfs,
external(id) {
if (id === 'static/src/magic.js') {
return '__magic';
}
},
workDir: 'static', // TODO: invalid for prod, but we don't use import.meta there
metaUrlScope: config.staticScope,
};
const bundles = await modernBuilder(staticEntrypoints, builderOptions);
const fallbackBundles = await Promise.all(Object.keys(fallbackEntrypoints).map((fallbackKey) => {
const localConfig = {[fallbackKey]: fallbackEntrypoints[fallbackKey]};
const options = Object.assign({commonJS: true}, builderOptions);
return modernBuilder(localConfig, options);
}));
fallbackBundles.forEach((all) => bundles.push(...all));
log(`Generated ${chalk.cyan(bundles.length)} bundles via Rollup, rewriting...`);
const transpileDeps = new Set([
'whatwg-fetch',
'./src/polyfill/classlist--toggle.js',
'./src/polyfill/element--closest.js',
'./src/polyfill/node.js',
'./src/polyfill/event.js',
]);
// Prepare rewriters for all scripts, and determine whether they need i18n at all.
const annotatedBundles = {};
const bundleTasks = bundles.map(async (bundle) => {
if (bundle.isDynamicEntry) {
// Santa Tracker doesn't handle these.
throw new TypeError(`dynamic entry unsupported: ${bundle.fileName}`);
} else if (bundle.facadeModuleId && path.dirname(bundle.fileName) !== path.dirname(bundle.facadeModuleId)) {
// Sanity-check expectations.
throw new TypeError(`unexpected divergence of fileName ${bundle.fileName} vs facadeModuleId ${bundle.facadeModuleId}`);
} else if (bundle.fileName in annotatedBundles) {
// We already have this for some reason (duplicate bundle).
throw new TypeError(`Already got output file: ${bundle.fileName}`);
}
const magic = sourceMagic();
const presets = [];
const plugins = [magic.plugin];
const transpile = (bundle.facadeModuleId in fallbackEntrypoints);
if (transpile) {
log(`Early transpiling ${chalk.yellow(bundle.fileName)}...`);
presets.push(
['@babel/preset-env', {
useBuiltIns: 'usage',
corejs: 3,
targets: {
browsers: ['ie >= 11'],
},
exclude: [
// Exclude these, otherwise the "__magic" import gets rewritten to require()
'@babel/plugin-transform-modules-commonjs',
'@babel/plugin-proposal-dynamic-import',
],
loose: true,
}],
);
plugins.push('@babel/plugin-transform-destructuring');
} else {
log(`Preparing ${chalk.yellow(bundle.fileName)}...`);
}
const transformOptions = {code: false, ast: true, presets, plugins};
const transformResult = await babel.transform(bundle.code, transformOptions);
if (transpile) {
// core-js gets included but not bundled: use this to find all deps
traverse.default(transformResult.ast, {
ImportDeclaration(nodePath) {
const path = nodePath.node.source.value;
if (importUtils.alreadyResolved(path)) {
throw new TypeError(`should only find unresolved additions by core-js, was: ${path}`);
}
transpileDeps.add(path);
nodePath.remove();
},
});
}
annotatedBundles[bundle.fileName] = {
ast: transformResult.ast,
transpile,
entrypoint: (bundle.facadeModuleId in staticEntrypoints || bundle.facadeModuleId in fallbackEntrypoints),
i18n: magic.seen('_msg'),
visit: magic.visit,
imports: bundle.imports.filter((x) => x !== '__magic'),
};
});
await Promise.all(bundleTasks);
// Special-case building support.js for static, based on all the core-js deps. We need to build
// it as an 'iife' here as we don't wrap it below.
const supportCode = Array.from(transpileDeps).map((dep) => `import '${dep}';\n`).join('');
const supportOutput = await modernBuilder(
{'static/support.js': supportCode}, {commonJS: true, workDir: 'static', format: 'iife'});
releaseWriter.file('static/support.js', await optionalMinify(supportOutput[0].code));
log(`Released support JS with ${chalk.yellow(transpileDeps.size)} deps...`);
// Determine the transitive properties of each bundle: their import tree (for preload) and if
// they must be re-compiled for i18n even _without_ messages.
let rewrittenSources = 0;
const buildAstVisitor = (lang) => {
return {
taggedTemplate(name, key) {
switch (name) {
case '_static':
return config.staticScope + key;
case '_msg':
if (lang !== null) {
const messages = langs[lang];
return messages(key);
}
default:
throw new TypeError(`unsupported magic: ${name}`);
}
},
rewriteImport(id) {
const bundle = annotatedBundles[id];
if (bundle && bundle.i18n) {
if (!lang) {
throw new TypeError(`Got bundle without lang request`);
}
return rewritePathForLang(id, lang);
}
},
};
};
// Loop over and mark i18n deps.
for (const fileName in annotatedBundles) {
const b = annotatedBundles[fileName];
if (b.i18n) {
continue;
}
// Mark all dependencies as _also_ needing i18n versions.
// If we're _not_ i18n, then check if one of our imports is (and then we have to be, too).
const work = new Set(b.imports);
for (const dep of work) {
const o = annotatedBundles[dep];
if (o.i18n) {
b.i18n = true;
continue;
}
o.imports.forEach((i) => work.add(i));
}
}
// Run again, to avoid race condition: we mark i18n above.
const workerTasks = [];
for (const fileName in annotatedBundles) {
const b = annotatedBundles[fileName];
// Sanity-check that we don't have a transpiled chunk?!
if (!b.entrypoint && b.transpile) {
throw new TypeError(`got bad bundle with (!entrypoint && transpile): ${fileName}`);
}
const langKeys = b.i18n ? Object.keys(langs) : [null];
workerTasks.push(...langKeys.map(async (lang) => {
const langFileName = rewritePathForLang(fileName, lang);
b.visit(fileName, buildAstVisitor(lang));
let {code} = generator.default(b.ast, {comments: false});
if (b.transpile) {
code = `;(function(){${code}\n/**/})();`; // gross
}
code = await optionalMinify(code);
releaseWriter.file(langFileName, code);
++rewrittenSources;
}));
const attrs = [];
b.entrypoint && attrs.push('entrypoint');
b.i18n && attrs.push('i18n');
b.transpile && attrs.push('transpile');
const prettyAttrs = attrs.map((attr) => chalk.magenta(attr)).join(',');
log(`Rewritten bundle ${chalk.yellow(fileName)} [${prettyAttrs}]...`);
}
log(`Waiting on ${chalk.cyan(workerTasks.length)} worker tasks...`);
await Promise.all(workerTasks);
log(`Rewrote ${chalk.cyan(rewrittenSources)} source files`);
// Now, rewrite all static HTML files for every language.
for (const [fileName, {dom, imports, dir}] of htmlDocuments) {
const importIsTranslated = [];
for (let i = 0; i < imports; ++i) {
// nb. This doesn't check support code, but it should be the same.
const i18n = annotatedBundles[`${dir}/${i}.js`].i18n;
importIsTranslated.push(i18n);
}
// Insert a fixed preamble that loads the correct JS.
if (imports) {
const pathToSupport = path.relative(path.dirname(fileName), 'static/support.js');
const document = dom.window.document;
const preamble = `
(function() {
var fallback = (location.search || '').match(/\\bfallback=1\\b/);
var all = ${JSON.stringify(importIsTranslated)}.map(function(i18n, i) {
return (fallback ? 'fallback-' : '') + i + (i18n ? '_' + document.documentElement.lang : '') + '.js';
});
fallback && all.unshift(${JSON.stringify(pathToSupport)});
(function next() {
var src = all.shift();
if (src) {
var node = document.createElement('script');
node.src = src;
if (fallback) {
node.onload = node.onerror = next;
} else {
node.setAttribute('type', 'module');
next();
}
document.head.appendChild(node);
}
})();
})();`
const scriptNode = document.createElement('script');
scriptNode.textContent = preamble;
document.body.append(scriptNode);
}
const applyLang = releaseHtml.buildApplyLang(dom);
for (const lang in langs) {
const serialized = applyLang(langs[lang]);
releaseWriter.file(rewritePathForLang(fileName, lang), serialized);
}
}
// Copy everything else (but filter prod assets if not requested).
const otherAssets = globAll(...assetsToCopy).concat(...requiredExternalSources)
const limitedOtherAssets = yargs.prod ? otherAssets : otherAssets.filter((f) => !f.startsWith('prod/'));
const otherAssetsCount = releaseWriter.all(limitedOtherAssets);
log(`Releasing ${chalk.cyan(otherAssetsCount)} static assets`);
// Display information about missing messages.
const missingMessagesKeys = Object.keys(missingMessages);
if (missingMessagesKeys.length) {
log(`Missing ${chalk.red(missingMessagesKeys.length)} messages:`);
missingMessagesKeys.forEach((msgid) => {
const missingLangs = missingMessages[msgid];
const ratio = (missingLangs.size / Object.keys(langs).length * 100).toFixed() + '%';
const rest = (missingLangs.size <= 10) ? `[${[...missingLangs]}]` : '';
console.info(chalk.yellow(msgid), 'for', chalk.red(ratio), 'of langs', rest);
});
}
// Write git hash and build version to a known location in prod.
// We don't use this in an actual production build (since they're not automated), but this is
// picked up by the staging code.
const siteHashConents = `${gitRevision}:${yargs.build}`;
log(`Written build hash: ${siteHashConents}`);
releaseWriter.file('prod/hash', siteHashConents);
// Wait for writing to complete and announce success! \o/
const count = await releaseWriter.wait();
log(`Done! Written ${chalk.cyan(count)} files`);
}
/**
* @return {!Map<string, string>} entrypoints that should be generated for prod
*/
async function findProdPages() {
// This is a bit gross but relies on this file to have an expected format.
const raw = await fsp.readFile('./static/src/strings/scenes.js', 'utf-8');
// nb. [^] matches anything _including_ newlines
const dictMatch = raw.match(/^export default (\{[^]*\})/m);
if (!dictMatch) {
throw new TypeError(`expected ./static/src/strings/scenes.js to contain "export default { ... }`);
}
const validInput = dictMatch[1].replace(/_msg`(\w+)`/g, (_, arg) => `'${arg}'`);
const pages = JSON5.parse(validInput);
if (!('index' in pages)) {
pages['index'] = pages[''] || '';
}
for (const key of Object.keys(pages)) {
// Remove Android-only scenes.
if (key.startsWith('@')) {
delete pages[key];
} else if (DISABLED_SCENES.includes(key)) {
delete pages[key];
}
}
delete pages['']; // don't explicitly generate blank top-level page
// Find any pages we might be missing.
const diskScenes = (await fsp.readdir('./static/scenes')).filter((cand) => cand.match(/^[a-z ]+/));
for (const page of diskScenes) {
if (!(page in pages)) {
pages[page] = '';
}
}
return pages;
}
async function releaseProd(langs) {
const prodPages = await findProdPages();
log(`Found ${chalk.cyan(Object.keys(prodPages).length)} prod pages`);
// Match non-index.html prod pages, like cast, error etc.
let prodHtmlCount = 0;
const prodOtherHtml = globAll('prod/*.html', '!prod/index.html');
for (const htmlFile of prodOtherHtml) {
const documentForLang = await releaseHtml.load(htmlFile);
const tail = path.basename(htmlFile);
for (const lang in langs) {
const target = path.join(prodPathForLang(lang), tail);
releaseWriter.file(target, documentForLang(langs[lang]));
++prodHtmlCount;
}
// Since this was a special entrypoint, remove it from normal generation of entrypoints, as it
// would just get clobbered anyway.
const page = path.basename(htmlFile, '.html');
delete prodPages[page];
}
// Fanout prod index.html to all scenes and langs.
for (const page in prodPages) {
// TODO(samthor): This loads and minifies the prod HTML ~scenes times, but it is destructive.
const documentForLang = await releaseHtml.load('prod/index.html', async (document) => {
const head = document.head;
// Load the entrypoint as a raw script, so it works everywhere, not just in module browsers.
const loaderNode = head.querySelector('script[type="module"]');
loaderNode.removeAttribute('type');
const image = `prod/images/og/${page}.png`;
if (await fsp.exists(image)) {
const url = `https://santatracker.google.com/images/og/${page}.png`;
const all = [
'[property="og:image"]',
'[name="twitter:image"]',
];
releaseHtml.applyAttributeToAll(head, all, 'content', url);
}
// nb. In 2019+, titles are just e.g. "Santa's Canvas", not "Santa's Canvas - Google Santa
// Tracker".
const msgid = prodPages[page] || 'meta_title';
const matched = releaseHtml.applyAttributeToAll(head, ['[data-title]'], 'msgid', msgid);
matched.forEach((n) => n.removeAttribute('data-title'));
});
for (const lang in langs) {
const target = path.join(prodPathForLang(lang), `${page}.html`);
releaseWriter.file(target, documentForLang(langs[lang]));
++prodHtmlCount;
}
}
log(`Generated ${chalk.cyan(prodHtmlCount)} prod pages`);
// Generate manifest.json for every language.
const manifest = require('./prod/manifest.json');
for (const lang in langs) {
const messages = langs[lang];
manifest['name'] = messages('santatracker');
manifest['short_name'] = messages('santa-app');
const target = path.join(prodPathForLang(lang), 'manifest.json');
releaseWriter.file(target, JSON.stringify(manifest));
}
log(`Generated ${chalk.cyan(Object.keys(langs).length)} manifest files`);
}
release().catch((err) => {
console.warn(err);
process.exit(1);
});

@ -1,209 +0,0 @@
/**
* Copyright 2020 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
const fsp = require('./build/fsp.js');
const path = require('path');
const compileStyles = require('./build/compile-santa-sass.js');
const compileScene = require('./build/compile-scene.js');
const JSON5 = require('json5');
function vfsLoader(plugins) {
plugins = plugins.filter(Boolean); // remove null plugins
const findSource = async (id) => {
if (await fsp.exists(id)) {
return null;
}
const ext = path.extname(id);
for (const plugin of plugins) {
// check mapping from extension to 'provider' which must exist
if (plugin.map) {
let cands = await plugin.map(ext) || [];
if (typeof cands === 'string') {
cands = [cands];
}
for (const cand of cands) {
const resolved = id.substr(0, id.length - ext.length) + cand;
if (await fsp.exists(resolved)) {
return {plugin, resolved};
}
}
}
// match filenames
if (plugin.match) {
const matched = await plugin.match(id);
if (matched) {
const resolved = typeof matched === 'string' ? matched : id;
return {plugin, resolved};
}
}
}
return null;
};
return async (id) => {
const result = await findSource(id);
if (!result) {
return null;
}
const {plugin, resolved} = result;
const out = await plugin.load(resolved);
if (out && out.map && this.addWatchFile) {
const sources = out.map.sources || [];
for (const source of sources) {
console.info('adding virtual watch', source);
this.addWatchFile(source);
}
}
return out;
};
}
function buildLangPlugin(id, lang) {
if (lang === null) {
return null;
}
const messages = require(`./_messages/${lang}.json`);
const fallback = require('./en_src_messages.json');
// Include untranslated strings from the English source.
for (const key in fallback) {
if (messages[key]) {
continue;
}
const untranslated = fallback[key];
messages[key] = {
message: untranslated['message'] || untranslated['raw'],
missing: true,
};
}
const raw = JSON.stringify(messages);
return {
match(idToMatch) {
if (idToMatch === id) {
return id;
}
},
load() {
return raw;
},
};
}
/**
* Builds a virtual filesystem for Santa Tracker.
*
* @param {string} staticScope the URL prefix to static content
* @param {{lang: string, compile: boolean, config: !Object<string, string>}=}
* @return {{load: function(string): ?}}
*/
module.exports = (staticScope, options) => {
options = Object.assign({
lang: null,
compile: true,
config: {},
}, options);
const stylesPlugin = {
map(ext) {
if (ext === '.css') {
return '.scss';
}
},
load(id) {
// FIXME: When ".css" files are loaded directly by the browser, the second two arguments are
// actually irrelevant, since we can just use relative URLs. It saves us ~bytes.
// nb. However, if styles are _inlined_, we're not nessecarily fixing the scope here.
return compileStyles(id, staticScope, 'static');
},
};
const configPlugin = {
match(id) {
if (id === 'prod/src/:config.json') {
return id;
}
},
load() {
return JSON.stringify(options.config);
},
};
const languagesPlugin = buildLangPlugin('static/:messages.json', options.lang);
const jsonPlugin = {
map(ext) {
if (ext === '.json') {
return '.json5';
}
},
async load(id) {
const raw = await fsp.readFile(id);
return JSON.stringify(JSON5.parse(raw));
}
};
// TODO(samthor): Closure doesn't have to be tied to scenes. But, it's mostly for historic code,
// so maybe it's not worth making it generic.
const closureSceneMatch = /^static\/scenes\/(\w+)\/:closure(|-\w+)\.js$/;
const closurePlugin = {
match(id) {
const rooted = path.relative(__dirname, id);
const m = closureSceneMatch.exec(rooted);
if (m) {
return rooted;
}
},
async load(id) {
const m = closureSceneMatch.exec(id);
const sceneName = m[1];
const flags = m[2];
if (flags) {
// nb. This previously allowed e.g., ':closure-typeSafe.js'.
throw new Error(`unsupported Closure flags: ${flags}`);
}
const {code, map} = await compileScene(sceneName, options.compile);
return {code, map};
},
};
const plugins = [
stylesPlugin,
configPlugin,
languagesPlugin,
jsonPlugin,
closurePlugin,
];
return vfsLoader(plugins);
};

@ -1,177 +0,0 @@
#!/usr/bin/env node
/**
* Copyright 2020 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
const chalk = require('chalk');
const fs = require('fs').promises;
const i18n = require('./build/i18n.js');
const santaVfs = require('./santa-vfs.js');
const vfsMiddleware = require('./build/modern-vfs-middleware.js');
const polka = require('polka');
const dhost = require('dhost').default;
const log = require('fancy-log');
const path = require('path');
const yargs = require('yargs')
.strict()
.epilogue('https://github.com/google/santa-tracker-web')
.option('port', {
alias: 'p',
type: 'number',
default: process.env.PORT || 8000,
describe: 'Static port',
})
.option('all', {
alias: 'a',
type: 'boolean',
default: false,
describe: 'Serve static on network address'
})
.option('prefix', {
type: 'string',
default: 'st',
describe: 'Static prefix',
coerce(v) {
return v.replace(/[^a-z0-9]/g, '') || 'st'; // ensure prefix is basic ascii only
},
requiresArg: true,
})
.option('lang', {
type: 'string',
default: 'en',
describe: 'Serving language',
})
.option('compile', {
type: 'boolean',
default: true,
describe: 'Compile Closure scenes',
})
.argv;
/**
* @param {polka.Polka} server
* @param {number} port
* @param {boolean} all
*/
function listen(server, port, all) {
// Always listen on IPv4 in dev.
return new Promise((resolve) => {
if (all) {
server.listen(port, '0.0.0.0', resolve);
} else {
server.listen(port, '127.0.0.1', resolve);
}
});
}
function clipboardCopy(v) {
try {
const clipboardy = require('clipboardy');
clipboardy.writeSync(v);
} catch (e) {
return e;
}
return null;
}
const messages = i18n(yargs.lang);
log(chalk.red(messages('santatracker')), `[${yargs.lang}]`);
// nb. matches config in release.js
const baseurl = `http://127.0.0.1:${yargs.port + 80}/`;
const config = {
staticScope: `${baseurl}${yargs.prefix}/`,
version: `dev-${(new Date).toISOString().replace(/[^\d]/g, '')}`,
baseurl,
};
async function serve() {
const vfs = santaVfs(config.staticScope, {
compile: yargs.compile,
lang: yargs.lang,
config,
});
const staticHost = dhost({
path: 'static',
cors: true,
serveLink: true,
});
const staticServer = polka();
staticServer.use(yargs.prefix, vfsMiddleware(vfs, 'static'), staticHost);
await listen(staticServer, yargs.port + 80, yargs.all);
log('Static', chalk.green(config.staticScope), yargs.all ? chalk.red('(on all interfaces)') : '');
const prodServer = polka();
const prodHtmlMiddleware = async (req, res, next) => {
// Match Google's serving infrastructure, and serve valid files under /intl/XX/.
const languageMatch = /^\/intl\/([-_\w]+)(\/|$)/.exec(req.path);
if (languageMatch) {
if (!languageMatch[2]) {
// fix "/intl/xx" => "/intl/xx/"
res.writeHead(301, {'Location': req.path + '/'});
return res.end();
}
req.path = '/' + req.path.substr(languageMatch[0].length);
}
let servePath = 'index.html';
const simplePathMatch = /^\/(\w+)\.html$/.exec(req.path);
if (simplePathMatch) {
const cand = `${simplePathMatch[1]}.html`;
try {
await fs.stat(path.join('prod', cand));
servePath = cand; // real file, serve instead of faux-"index.html"
} catch (e) {
// ignore, not a real file
}
} else if (req.path !== '/') {
return next();
}
// Serve the raw HTML.
const filename = path.join('prod', servePath);
const content = await fs.readFile(filename, 'utf-8');
res.writeHead(200, {'Content-Type': 'text/html; charset=utf-8'});
res.end(content);
};
prodServer.use(
prodHtmlMiddleware,
vfsMiddleware(vfs, 'prod'),
dhost({path: 'prod', listing: false}),
);
// Listen, copy and announce prod URL.
await listen(prodServer, yargs.port);
const prodURL = `http://localhost:${yargs.port}`;
const clipboardError = clipboardCopy(prodURL);
const suffix = clipboardError ? chalk.red('(could not copy to clipboard)') : chalk.dim('(on your clipboard!)');
log('Prod', chalk.greenBright(prodURL), suffix);
}
serve().catch((err) => {
console.warn(err);
process.exit(1);
});

@ -42,6 +42,7 @@
</div>
<!-- TODO(samthor): this is 2.6mb (!), include minified version -->
<!-- <script src="/phaser-arcade-physics.min.js"></script> -->
<script src="./phaser-arcade-physics.js"></script>
<script type="module">
import api from '../../src/scene/api.js';

@ -1,76 +0,0 @@
#!/usr/bin/env node
/**
* Copyright 2020 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* @fileoverview Removes notes from Lottie files.
*/
function cleanupNotes(o) {
if (typeof o !== 'object') {
return;
}
if (Array.isArray(o)) {
o.forEach(cleanupNotes);
return;
}
delete o['nm'];
delete o['mn'];
//delete o['v'];
for (const key in o) {
const cand = o[key];
if (!cand) {
// Can't remove falsey values (maybe some?)
} else {
cleanupNotes(cand);
}
}
}
const files = process.argv.slice(2);