This commit is contained in:
@@ -0,0 +1,88 @@
|
|||||||
|
name: Nightly build & deploy
|
||||||
|
|
||||||
|
# Rebuilds the site (refreshing the Discord embed's "today" snapshot), uploads
|
||||||
|
# the built dist/ to the CMU AFS space over SFTP, then triggers CMU's publish
|
||||||
|
# CGI so the files go live.
|
||||||
|
on:
|
||||||
|
# Midnight run. NOTE: cron here is UTC. 00:00 UTC is 8pm/7pm Eastern.
|
||||||
|
# For midnight *Eastern*, use "0 5 * * *" (EST) or "0 4 * * *" (EDT).
|
||||||
|
schedule:
|
||||||
|
- cron: "0 5 * * *"
|
||||||
|
# Allow manual runs from the Gitea Actions UI.
|
||||||
|
workflow_dispatch: {}
|
||||||
|
# Also deploy on pushes to the main branch.
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-deploy:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
env:
|
||||||
|
# Absolute base URL where the site is served. Used to build the absolute
|
||||||
|
# icon URL embedded for Discord. Set SITE_BASE_URL as a secret/variable,
|
||||||
|
# e.g. https://www.andrew.cmu.edu/user/<username>
|
||||||
|
SITE_BASE_URL: ${{ secrets.SITE_BASE_URL }}
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Node
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: "20"
|
||||||
|
cache: "npm"
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Build (injects today's Red-plan embed)
|
||||||
|
env:
|
||||||
|
# Discord fetches this absolute URL for the embed thumbnail.
|
||||||
|
SITE_ICON_URL: "${{ secrets.SITE_BASE_URL }}/apple-touch-icon.png"
|
||||||
|
run: npm run build
|
||||||
|
|
||||||
|
- name: Install lftp
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y lftp
|
||||||
|
|
||||||
|
- name: Upload dist/ via SFTP
|
||||||
|
env:
|
||||||
|
SFTP_HOST: ${{ secrets.SFTP_HOST }}
|
||||||
|
SFTP_PORT: ${{ secrets.SFTP_PORT }}
|
||||||
|
SFTP_USERNAME: ${{ secrets.SFTP_USERNAME }}
|
||||||
|
SFTP_PASSWORD: ${{ secrets.SFTP_PASSWORD }}
|
||||||
|
SFTP_REMOTE_PATH: ${{ secrets.SFTP_REMOTE_PATH }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
PORT="${SFTP_PORT:-22}"
|
||||||
|
|
||||||
|
# Mirror the local dist/ into the remote path.
|
||||||
|
# -R : reverse mirror (local -> remote)
|
||||||
|
# --delete : remove remote files no longer present locally
|
||||||
|
# --verbose : log transferred files
|
||||||
|
# Host key checking is disabled because CMU's key is not pre-seeded on
|
||||||
|
# the runner; the connection is still encrypted.
|
||||||
|
lftp -c "
|
||||||
|
set sftp:auto-confirm yes;
|
||||||
|
set net:max-retries 3;
|
||||||
|
set net:timeout 20;
|
||||||
|
open -u '${SFTP_USERNAME}','${SFTP_PASSWORD}' sftp://${SFTP_HOST}:${PORT};
|
||||||
|
mirror -R --delete --verbose ./dist/ '${SFTP_REMOTE_PATH}';
|
||||||
|
bye
|
||||||
|
"
|
||||||
|
|
||||||
|
- name: Trigger CMU publish
|
||||||
|
env:
|
||||||
|
SFTP_USERNAME: ${{ secrets.SFTP_USERNAME }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
# FLAG=0 publishes; NAME is the AFS username (same as the SFTP user).
|
||||||
|
# --fail makes curl exit non-zero on HTTP errors so the job fails
|
||||||
|
# loudly if publishing is rejected.
|
||||||
|
curl --fail --show-error --silent \
|
||||||
|
"https://www.andrew.cmu.edu/cgi-bin/publish?FLAG=0&NAME=${SFTP_USERNAME}"
|
||||||
|
echo "Publish request sent for ${SFTP_USERNAME}."
|
||||||
@@ -38,7 +38,8 @@ npm run dev # start the dev server at http://localhost:5173
|
|||||||
### Other scripts
|
### Other scripts
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run build # type-check and produce a production build in dist/
|
npm run build # type-check, build to dist/, then inject the Discord embed
|
||||||
|
npm run embed # (re)inject the Discord embed into an existing dist/ build
|
||||||
npm run preview # serve the production build locally
|
npm run preview # serve the production build locally
|
||||||
npm test # run the estimation-engine tests
|
npm test # run the estimation-engine tests
|
||||||
```
|
```
|
||||||
@@ -136,6 +137,71 @@ Because `config.json` sits alongside `index.html` in the deployed files and is f
|
|||||||
runtime, you can update plan numbers, dates, or breaks by editing the deployed
|
runtime, you can update plan numbers, dates, or breaks by editing the deployed
|
||||||
`config.json` directly. No rebuild or redeploy of the JS is required — just reload the page.
|
`config.json` directly. No rebuild or redeploy of the JS is required — just reload the page.
|
||||||
|
|
||||||
|
## Discord link embed
|
||||||
|
|
||||||
|
The page's `<head>` includes a Discord [Components V2](https://discord.com/developers/docs/components/reference)
|
||||||
|
link embed (`<script id="discord:component-embed" type="application/json">`). When the site URL is
|
||||||
|
pasted into Discord, its crawler reads that JSON and renders a rich card showing the site name, a
|
||||||
|
short blurb, the site icon, and the **Red plan's on-pace estimate for the current day** (blocks
|
||||||
|
left, FLEX left, suggested weekly pace).
|
||||||
|
|
||||||
|
Because Discord's crawler reads the static HTML and does not run the page's JavaScript, this data
|
||||||
|
is baked in at build time by [`scripts/inject-embed.mjs`](scripts/inject-embed.mjs). That script
|
||||||
|
uses the same estimation engine the app uses, so the embed and the app always agree. Two
|
||||||
|
consequences worth knowing:
|
||||||
|
|
||||||
|
- The embed is a **snapshot from the moment of the build** — it does not update on its own. The
|
||||||
|
nightly deploy (below) rebuilds it so it stays current.
|
||||||
|
- It cannot show per-user results, since those only exist after the JS runs in a real browser.
|
||||||
|
|
||||||
|
`SITE_ICON_URL` controls the absolute icon URL Discord fetches; the deploy workflow sets it from
|
||||||
|
`SITE_BASE_URL`.
|
||||||
|
|
||||||
|
## Automated nightly deployment (Gitea Actions)
|
||||||
|
|
||||||
|
[`.gitea/workflows/deploy.yml`](.gitea/workflows/deploy.yml) rebuilds the site every night
|
||||||
|
(refreshing the embed snapshot), uploads `dist/` to CMU AFS space over SFTP, and then triggers
|
||||||
|
CMU's publish CGI to make the files live. It also runs on pushes to `main` and can be triggered
|
||||||
|
manually from the Gitea Actions UI.
|
||||||
|
|
||||||
|
### Required secrets
|
||||||
|
|
||||||
|
Set these in your Gitea repo under **Settings → Actions → Secrets**:
|
||||||
|
|
||||||
|
| Secret | Required | Description |
|
||||||
|
| ------------------ | -------- | --------------------------------------------------------------------------- |
|
||||||
|
| `SFTP_HOST` | yes | SFTP server hostname (e.g. `unix.andrew.cmu.edu`). |
|
||||||
|
| `SFTP_USERNAME` | yes | Your Andrew (AFS) username. Also used for the publish callback. |
|
||||||
|
| `SFTP_PASSWORD` | yes | Password for the SFTP account. |
|
||||||
|
| `SFTP_REMOTE_PATH` | yes | Absolute remote directory to upload into (e.g. `/afs/andrew.cmu.edu/usr/<user>/www/meal-estimator`). |
|
||||||
|
| `SITE_BASE_URL` | yes | Public base URL of the site (e.g. `https://www.andrew.cmu.edu/user/<user>`). Used to build the embed icon URL. No trailing slash. |
|
||||||
|
| `SFTP_PORT` | no | SFTP port; defaults to `22` if unset. |
|
||||||
|
|
||||||
|
### What the workflow does
|
||||||
|
|
||||||
|
1. Checks out the repo and installs dependencies with `npm ci`.
|
||||||
|
2. Runs `npm run build`, which type-checks, bundles, and injects the current day's Red-plan embed
|
||||||
|
(with `SITE_ICON_URL` derived from `SITE_BASE_URL`).
|
||||||
|
3. Uploads `dist/` to `SFTP_REMOTE_PATH` using `lftp mirror -R --delete` (the remote is mirrored
|
||||||
|
to match the build; files no longer present locally are removed).
|
||||||
|
4. Calls `https://www.andrew.cmu.edu/cgi-bin/publish?FLAG=0&NAME=<SFTP_USERNAME>` to publish. The
|
||||||
|
step fails loudly if the publish request returns an HTTP error.
|
||||||
|
|
||||||
|
### Scheduling note
|
||||||
|
|
||||||
|
The cron in the workflow is in **UTC**. It is set to `0 5 * * *`, which is midnight US Eastern
|
||||||
|
during EST. For EDT (daylight saving), use `0 4 * * *`. Adjust to taste, or keep both in mind when
|
||||||
|
the clocks change.
|
||||||
|
|
||||||
|
### Security notes
|
||||||
|
|
||||||
|
- The SFTP password is passed to `lftp` via an environment variable interpolated into the command;
|
||||||
|
keep it only in Gitea secrets, never in the repo.
|
||||||
|
- Host-key checking is disabled on the runner (`sftp:auto-confirm yes`) because CMU's host key is
|
||||||
|
not pre-seeded there. The transfer is still encrypted, but this does not verify the server's
|
||||||
|
identity. If you want strict host-key verification, add the server's key to the runner and
|
||||||
|
remove that setting.
|
||||||
|
|
||||||
## Data persistence
|
## Data persistence
|
||||||
|
|
||||||
The selected plan, checked breaks, and additional-days value are saved to the browser's
|
The selected plan, checked breaks, and additional-days value are saved to the browser's
|
||||||
@@ -147,9 +213,10 @@ are ignored on load.
|
|||||||
## Project structure
|
## Project structure
|
||||||
|
|
||||||
```
|
```
|
||||||
index.html # app shell
|
index.html # app shell (includes the Discord embed placeholder)
|
||||||
public/
|
public/
|
||||||
config.json # all tunable values (plans, dates, breaks); served + fetched at runtime
|
config.json # all tunable values (plans, dates, breaks); served + fetched at runtime
|
||||||
|
favicon.svg # site icon (fork + progress arc)
|
||||||
src/
|
src/
|
||||||
types.ts # shared types
|
types.ts # shared types
|
||||||
dates.ts # whole-day date helpers
|
dates.ts # whole-day date helpers
|
||||||
@@ -162,6 +229,10 @@ src/
|
|||||||
style.css # dark CMU-accented theme
|
style.css # dark CMU-accented theme
|
||||||
engine.test.ts # engine unit tests
|
engine.test.ts # engine unit tests
|
||||||
sanity.test.ts # checks against the shipped config
|
sanity.test.ts # checks against the shipped config
|
||||||
|
scripts/
|
||||||
|
inject-embed.mjs # build-time: injects today's Red-plan Discord embed into dist/
|
||||||
|
.gitea/workflows/
|
||||||
|
deploy.yml # nightly build + SFTP deploy + CMU publish
|
||||||
```
|
```
|
||||||
|
|
||||||
## Tech stack
|
## Tech stack
|
||||||
|
|||||||
+21
@@ -7,6 +7,27 @@
|
|||||||
<link rel="icon" type="image/svg+xml" href="./favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="./favicon.svg" />
|
||||||
<link rel="icon" type="image/x-icon" href="./favicon.ico" sizes="any" />
|
<link rel="icon" type="image/x-icon" href="./favicon.ico" sizes="any" />
|
||||||
<link rel="apple-touch-icon" href="./apple-touch-icon.png" />
|
<link rel="apple-touch-icon" href="./apple-touch-icon.png" />
|
||||||
|
|
||||||
|
<!-- Standard link-preview metadata (Open Graph / Twitter) -->
|
||||||
|
<meta property="og:type" content="website" />
|
||||||
|
<meta property="og:title" content="CMU Meal Estimator" />
|
||||||
|
<meta
|
||||||
|
property="og:description"
|
||||||
|
content="See the meal blocks and FLEX dollars a CMU student should have used by any date."
|
||||||
|
/>
|
||||||
|
<meta property="og:image" content="./apple-touch-icon.png" />
|
||||||
|
<meta name="twitter:card" content="summary" />
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Discord Components V2 link embed. The content below is a placeholder that
|
||||||
|
is replaced at build time (scripts/inject-embed.mjs) with the current
|
||||||
|
day's Red-plan estimate. Discord's crawler reads this static JSON; it is
|
||||||
|
never executed by the browser.
|
||||||
|
-->
|
||||||
|
<script id="discord:component-embed" type="application/json">
|
||||||
|
__DISCORD_EMBED_JSON__
|
||||||
|
</script>
|
||||||
|
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
<link
|
<link
|
||||||
|
|||||||
+2
-1
@@ -5,7 +5,8 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc --noEmit && vite build",
|
"build": "tsc --noEmit && vite build && npm run embed",
|
||||||
|
"embed": "vite-node scripts/inject-embed.mjs",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"test": "vitest run"
|
"test": "vitest run"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,199 @@
|
|||||||
|
/**
|
||||||
|
* Build-time injector for the Discord Components V2 link embed.
|
||||||
|
*
|
||||||
|
* Computes the Red plan, no-breaks, "as of today" estimate using the SAME
|
||||||
|
* engine the app uses, formats a rich embed, and replaces the
|
||||||
|
* __DISCORD_EMBED_JSON__ placeholder in dist/index.html with the final,
|
||||||
|
* properly-escaped JSON.
|
||||||
|
*
|
||||||
|
* Run AFTER `vite build` (see the "build" script in package.json). It is
|
||||||
|
* executed with vite-node so it can import the TypeScript engine/config
|
||||||
|
* directly, guaranteeing the embed and the app agree.
|
||||||
|
*
|
||||||
|
* The snapshot reflects the moment of the build. The nightly Gitea workflow
|
||||||
|
* rebuilds + redeploys so it stays current.
|
||||||
|
*/
|
||||||
|
import { readFileSync, writeFileSync } from "node:fs";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { dirname, resolve } from "node:path";
|
||||||
|
|
||||||
|
import { loadConfig } from "../src/configLoader.ts";
|
||||||
|
import { estimate } from "../src/engine.ts";
|
||||||
|
import { atMidnight, toISODate } from "../src/dates.ts";
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const root = resolve(__dirname, "..");
|
||||||
|
|
||||||
|
const PLACEHOLDER = "__DISCORD_EMBED_JSON__";
|
||||||
|
const PLAN_ID = "red";
|
||||||
|
const ACCENT_COLOR = 0xc8102e; // CMU Tartan red, as a decimal int for Discord.
|
||||||
|
const SITE_NAME = "CMU Meal Estimator";
|
||||||
|
const SITE_BLURB =
|
||||||
|
"See how many meal blocks and FLEX dollars you should have used by any date.";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Absolute URL to the site icon shown in the embed. Discord fetches this over
|
||||||
|
* the network, so it must be an absolute, publicly reachable URL. Override it
|
||||||
|
* per deployment with the SITE_ICON_URL env var (the Gitea workflow sets this
|
||||||
|
* from the deploy path). Falls back to a sensible default.
|
||||||
|
*/
|
||||||
|
const SITE_ICON_URL =
|
||||||
|
process.env.SITE_ICON_URL ??
|
||||||
|
"https://www.andrew.cmu.edu/user/REPLACE_ME/apple-touch-icon.png";
|
||||||
|
|
||||||
|
/* ---------- formatting helpers (mirror the app) ---------- */
|
||||||
|
|
||||||
|
const fmtBlocksWhole = (n) => Math.round(n).toLocaleString("en-US");
|
||||||
|
const fmtBlocks = (n) =>
|
||||||
|
(Math.round(n * 10) / 10).toLocaleString("en-US", {
|
||||||
|
maximumFractionDigits: 1,
|
||||||
|
});
|
||||||
|
const fmtMoney = (n) =>
|
||||||
|
`$${n.toLocaleString("en-US", {
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
})}`;
|
||||||
|
|
||||||
|
function niceDate(d) {
|
||||||
|
return d.toLocaleDateString("en-US", {
|
||||||
|
weekday: "long",
|
||||||
|
month: "long",
|
||||||
|
day: "numeric",
|
||||||
|
year: "numeric",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- compute today's Red-plan snapshot ---------- */
|
||||||
|
|
||||||
|
function computeEmbed() {
|
||||||
|
const raw = JSON.parse(
|
||||||
|
readFileSync(resolve(root, "public/config.json"), "utf-8")
|
||||||
|
);
|
||||||
|
const result = loadConfig(raw);
|
||||||
|
if (!result.ok) {
|
||||||
|
throw new Error(
|
||||||
|
`config.json failed validation:\n - ${result.errors.join("\n - ")}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const config = result.config;
|
||||||
|
|
||||||
|
const plan = config.plans.find((p) => p.id === PLAN_ID);
|
||||||
|
if (!plan) {
|
||||||
|
throw new Error(
|
||||||
|
`Plan "${PLAN_ID}" not found in config. Available: ${config.plans
|
||||||
|
.map((p) => p.id)
|
||||||
|
.join(", ")}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const today = atMidnight(new Date());
|
||||||
|
const r = estimate(config, {
|
||||||
|
selectedPlanId: PLAN_ID,
|
||||||
|
targetDate: today,
|
||||||
|
activeMissingPeriodIds: new Set(),
|
||||||
|
extraExcludedDays: 0,
|
||||||
|
actualMealBlocks: null,
|
||||||
|
actualFlexDollars: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Where "today" falls relative to the meal window, for friendlier copy.
|
||||||
|
const beforeStart = today.getTime() < config.mealStartDate.getTime();
|
||||||
|
const afterEnd = today.getTime() > config.mealEndDate.getTime();
|
||||||
|
|
||||||
|
const header = `# ${SITE_NAME}\n${SITE_BLURB}`;
|
||||||
|
|
||||||
|
let body;
|
||||||
|
if (beforeStart) {
|
||||||
|
body =
|
||||||
|
`\n\n**${plan.displayName} plan — not started yet**\n` +
|
||||||
|
`The meal period begins ${niceDate(config.mealStartDate)}. ` +
|
||||||
|
`You start with **${fmtBlocksWhole(plan.totalMealBlocks)} blocks** and ` +
|
||||||
|
`**${fmtMoney(plan.totalFlexDollars)} FLEX**.`;
|
||||||
|
} else if (afterEnd) {
|
||||||
|
body =
|
||||||
|
`\n\n**${plan.displayName} plan — meal period ended**\n` +
|
||||||
|
`The period ended ${niceDate(config.mealEndDate)}.`;
|
||||||
|
} else {
|
||||||
|
body =
|
||||||
|
`\n\n**${plan.displayName} plan · as of ${niceDate(today)}**\n` +
|
||||||
|
`> 🍴 **Blocks left:** ${fmtBlocksWhole(r.remainingMealBlocks)} of ${fmtBlocksWhole(
|
||||||
|
plan.totalMealBlocks
|
||||||
|
)}\n` +
|
||||||
|
`> 💳 **FLEX left:** ${fmtMoney(r.remainingFlexDollars)} of ${fmtMoney(
|
||||||
|
plan.totalFlexDollars
|
||||||
|
)}\n` +
|
||||||
|
`> 📆 **Suggested pace:** ${fmtBlocks(r.perWeekMealBlocks)} blocks + ${fmtMoney(
|
||||||
|
r.perWeekFlexDollars
|
||||||
|
)} / week\n` +
|
||||||
|
`-# On-pace estimate, no breaks excluded. Adjust your own plan & dates on the site.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = header + body;
|
||||||
|
|
||||||
|
// Discord Components V2 payload:
|
||||||
|
// type 17 = Container (carries the accent color)
|
||||||
|
// type 9 = Section (text with a right-aligned accessory)
|
||||||
|
// type 10 = Text Display (the markdown content)
|
||||||
|
// accessory type 11 = Thumbnail (the site icon)
|
||||||
|
// type 14 = Separator
|
||||||
|
// type 10 = Text Display (footer link line)
|
||||||
|
const payload = {
|
||||||
|
component: {
|
||||||
|
type: 17,
|
||||||
|
accent_color: ACCENT_COLOR,
|
||||||
|
components: [
|
||||||
|
{
|
||||||
|
type: 9,
|
||||||
|
components: [{ type: 10, content }],
|
||||||
|
accessory: {
|
||||||
|
type: 11,
|
||||||
|
media: { url: SITE_ICON_URL },
|
||||||
|
description: `${SITE_NAME} icon`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ type: 14 },
|
||||||
|
{
|
||||||
|
type: 10,
|
||||||
|
content: `-# 🔗 Open the estimator to pick your own plan, date, and breaks.`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return { payload, meta: { plan: plan.displayName, date: toISODate(today) } };
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- inject into dist/index.html ---------- */
|
||||||
|
|
||||||
|
function main() {
|
||||||
|
const htmlPath = resolve(root, "dist/index.html");
|
||||||
|
let html;
|
||||||
|
try {
|
||||||
|
html = readFileSync(htmlPath, "utf-8");
|
||||||
|
} catch {
|
||||||
|
throw new Error(
|
||||||
|
`dist/index.html not found. Run "vite build" before this script.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!html.includes(PLACEHOLDER)) {
|
||||||
|
throw new Error(
|
||||||
|
`Placeholder ${PLACEHOLDER} not found in dist/index.html. ` +
|
||||||
|
`Was index.html changed?`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { payload, meta } = computeEmbed();
|
||||||
|
|
||||||
|
// JSON.stringify produces valid, fully-escaped JSON — safe to drop between
|
||||||
|
// the <script type="application/json"> tags.
|
||||||
|
const json = JSON.stringify(payload, null, 2);
|
||||||
|
|
||||||
|
writeFileSync(htmlPath, html.replace(PLACEHOLDER, json), "utf-8");
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`Injected Discord embed (${meta.plan} plan, as of ${meta.date}) into dist/index.html`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
Reference in New Issue
Block a user