Merged in feature/copy-static-files-via-build-scripts (pull request #2798)

SW-3467 Copy static files via build scripts

* add file copy script and add all fonts to design-system

* add file copy script and add all fonts to design-system

* add file copy script and add all fonts to design-system

* remove fonts that will be copied via build scripts

* wip

* update paths to shared files

* update material-symbol script

* merge

* fix missing shared segment for path in fonts.css


Approved-by: Linus Flood
This commit is contained in:
Joakim Jäderberg
2025-09-16 10:59:33 +00:00
parent 33ce30a1c8
commit 48324ef935
220 changed files with 71875 additions and 725 deletions

173
scripts/copyFiles.ts Normal file
View File

@@ -0,0 +1,173 @@
#!/usr/bin/env tsx
import { Command } from "commander";
import { promises as fs } from "node:fs";
import * as path from "node:path";
async function ensureDir(dir: string): Promise<void> {
await fs.mkdir(dir, { recursive: true });
}
async function copyDir(
src: string,
dest: string,
includeExts: string[] = [],
excludeExts: string[] = []
): Promise<void> {
await ensureDir(dest);
const entries = await fs.readdir(src, { withFileTypes: true });
for (const entry of entries) {
const srcPath = path.join(src, entry.name);
const destPath = path.join(dest, entry.name);
if (entry.isDirectory()) {
await copyDir(srcPath, destPath, includeExts, excludeExts);
} else if (entry.isFile()) {
if (!shouldCopyFile(entry.name, includeExts, excludeExts)) {
continue;
}
await fs.copyFile(srcPath, destPath);
}
}
}
function parseExtList(list?: string): string[] {
if (!list) return [];
return list
.split(",")
.map((s) => s.trim())
.filter(Boolean)
.map((e) =>
e.startsWith(".") ? e.toLowerCase() : `.${e.toLowerCase()}`
);
}
function isMultiPartPattern(pattern: string): boolean {
// true for patterns like '.d.ts' or '.spec.ts' (contains an extra dot after the leading one)
return pattern.slice(1).includes(".");
}
function matchesPattern(fileNameLower: string, pattern: string): boolean {
if (isMultiPartPattern(pattern)) {
// match by filename suffix for multi-segment patterns
return fileNameLower.endsWith(pattern);
}
// single-segment patterns: compare the file's last extension exactly (prevents '.ts' matching '.tsx')
return path.extname(fileNameLower) === pattern;
}
function shouldCopyFile(
fileName: string,
includePatterns: string[],
excludePatterns: string[]
): boolean {
const fileLower = fileName.toLowerCase();
// Exclude takes precedence
if (
excludePatterns.length > 0 &&
excludePatterns.some((p) => matchesPattern(fileLower, p))
) {
return false;
}
// If no include patterns provided, include everything not excluded
if (includePatterns.length === 0) return true;
// Otherwise include only if at least one include pattern matches
return includePatterns.some((p) => matchesPattern(fileLower, p));
}
async function run(
sourceArg: string,
destArg: string,
includeExts: string[] = [],
excludeExts: string[] = []
): Promise<{ src: string; dest: string }> {
const src = path.resolve(process.cwd(), sourceArg);
const dest = path.resolve(process.cwd(), destArg);
// Validate source exists
let stat: any;
try {
stat = await fs.stat(src);
} catch {
throw new Error(`Source path does not exist: ${src}`);
}
if (!stat.isDirectory()) {
throw new Error(`Source must be a directory: ${src}`);
}
// Prevent copying into source or a parent of source
const rel = path.relative(src, dest);
if (!rel || !rel.startsWith("..")) {
throw new Error(
"Destination must not be inside (or be) the source directory."
);
}
await ensureDir(dest);
await copyDir(src, dest, includeExts, excludeExts);
return { src, dest };
}
const program = new Command();
program
.name("copy-files")
.description(
"Recursively copy files from a source directory to a destination directory."
)
.argument("<source-path>", "Source directory")
.argument("<destination-path>", "Destination directory")
.option("-q, --quiet", "Suppress success output")
.option(
"--include-ext <exts>",
"Comma-separated list of file extensions to include, e.g. '.js,.css' or 'js,css'"
)
.option(
"--exclude-ext <exts>",
"Comma-separated list of file extensions to exclude, e.g. '.map,.tmp' or 'map,tmp'"
)
.action(
async (
sourcePath: string,
destinationPath: string,
options: {
quiet?: boolean;
includeExt?: string;
excludeExt?: string;
}
) => {
try {
const includeExts = parseExtList(options.includeExt);
const excludeExts = parseExtList(options.excludeExt);
const result = await run(
sourcePath,
destinationPath,
includeExts,
excludeExts
);
if (!options.quiet) {
console.log(
`Copied files from "${result.src}" to "${result.dest}".`
);
}
} catch (err) {
console.error(err instanceof Error ? err.message : String(err));
process.exit(1);
}
}
);
// Show examples in help
program.addHelpText(
"after",
`
Examples:
tsx scripts/copyFiles.ts static/fonts dist/fonts
tsx scripts/copyFiles.ts assets/images build/images
tsx scripts/copyFiles.ts src dist --include-ext .js,.css
tsx scripts/copyFiles.ts src dist --exclude-ext map,tmp
`
);
program.parseAsync(process.argv);

View File

@@ -1,345 +0,0 @@
// @ts-check
import crypto from 'node:crypto'
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'
import { createWriteStream } from 'node:fs'
import { resolve, join } from 'node:path'
import { Readable } from 'node:stream'
import { pipeline } from 'node:stream/promises'
import stringify from 'json-stable-stringify-without-jsonify'
// Defines where the font lives
const DESIGN_SYSTEM_FONT_DIR = `./packages/design-system/public/_static/fonts/material-symbols`
const WEB_FONT_DIR = `./apps/scandic-web/public/_static/fonts/material-symbols`
const SAS_FONT_DIR = `./apps/partner-sas/public/_static/fonts/material-symbols`
// Defines the settings for the font
const FONT_BASE_URL = `https://fonts.googleapis.com/css2?family=Material+Symbols+Rounded:opsz,wght,FILL,GRAD@24,400,0..1,0`
// Defines the subset of icons for the font
const icons = [
'accessibility',
'accessible',
'add',
'add_circle',
'air',
'air_purifier_gen',
'airline_seat_recline_normal',
'airplane_ticket',
'apartment',
'apparel',
'arrow_back',
'arrow_back_ios',
'arrow_forward_ios',
'arrow_forward',
'arrow_right',
'arrow_upward',
'assistant_navigation',
'asterisk',
'attractions',
'award_star',
'bakery_dining',
'balcony',
'bathroom',
'bathtub',
'beach_access',
'bed',
'bedroom_parent',
'box',
'business_center',
'calendar_add_on',
'calendar_clock',
'calendar_month',
'calendar_today',
'call',
'call_quality',
'camera',
'photo_camera',
'cancel',
'chair',
'charging_station',
'check',
'check_box',
'check_circle',
'checked_bag',
'checkroom',
'chevron_left',
'chevron_right',
'close',
'coffee',
'coffee_maker',
'compare_arrows',
'computer',
'concierge',
'confirmation_number',
'connected_tv',
'content_copy',
'brunch_dining',
'contract',
'cool_to_dry',
'countertops',
'credit_card',
'credit_card_heart',
'curtains',
'curtains_closed',
'deck',
'delete',
'desk',
'device_thermostat',
'diamond',
'dining',
'directions',
'directions_run',
'directions_subway',
'downhill_skiing',
'download',
'dresser',
'edit',
'edit_calendar',
'edit_square',
'electric_bike',
'electric_car',
'elevator',
'emoji_transportation',
'error',
'error_circle_rounded',
'exercise',
'family_restroom',
'fastfood',
'favorite',
'fax',
'featured_seasonal_and_gifts',
'festival',
'filter',
'filter_alt',
'floor_lamp',
'forest',
'garage',
'globe',
'golf_course',
'groups',
'health_and_beauty',
'heat',
'hiking',
'home',
'hot_tub',
'houseboat',
'hvac',
'id_card',
'imagesmode',
'info',
'iron',
'kayaking',
'kettle',
'keyboard_arrow_down',
'keyboard_arrow_up',
'king_bed',
'kitchen',
'landscape',
'laundry',
'link',
'liquor',
'live_tv',
'local_bar',
'local_cafe',
'local_convenience_store',
'local_drink',
'local_laundry_service',
'local_parking',
'location_city',
'location_on',
'lock',
'loyalty',
'luggage',
'mail',
'map',
'meeting_room',
'microwave',
'mode_fan',
'museum',
'music_cast',
'music_note',
'nature',
'night_shelter',
'nightlife',
'open_in_new',
'pan_zoom',
'panorama',
'pedal_bike',
'person',
'pets',
'phone',
'pool',
'print',
'radio',
'recommend',
'redeem',
'refresh',
'remove',
'restaurant',
'room_service',
'router',
'sailing',
'sauna',
'scene',
'search',
'sell',
'shopping_bag',
'shower',
'single_bed',
'skateboarding',
'smoke_free',
'smoking_rooms',
'spa',
'sports_esports',
'sports_golf',
'sports_handball',
'sports_tennis',
'stairs',
'star',
'straighten',
'styler',
'support_agent',
'swipe',
'sync_saved_locally',
'table_bar',
'theater_comedy',
'things_to_do',
'train',
'tram',
'transit_ticket',
'travel',
'trophy',
'travel_luggage_and_bags',
'tv_guide',
'tv_remote',
'upload',
'visibility',
'visibility_off',
'ward',
'warning',
'water_full',
'wifi',
'yard',
].sort()
function createHash(value) {
const stringified = stringify(value)
const hash = crypto.createHash('sha256')
hash.update(stringified)
return hash.digest('hex')
}
const hash = createHash(icons).substring(0, 8)
async function fetchIconUrl(url) {
const response = await fetch(url, {
headers: {
Accept:
'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7',
'User-Agent':
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36',
},
})
if (!response.ok) {
console.error(`Unable to fetch woff2 for ${url}`)
process.exit(1)
}
const text = await response.text()
const isWoff2 = /format\('woff2'\)/.test(text)
if (!isWoff2) {
console.error(`Unable to identify woff2 font in response`)
process.exit(1)
}
const srcUrl = text.match(/src: url\(([^)]+)\)/)
if (srcUrl && srcUrl[1]) {
return srcUrl[1]
}
return null
}
async function download(url, destFolder) {
const dest = resolve(join(destFolder, `/rounded-${hash}.woff2`))
try {
const response = await fetch(url)
if (!response.ok) {
console.error(`Unable to fetch ${url}`)
process.exit(1)
}
if (!response.body) {
console.error(`Bad response from ${url}`)
process.exit(1)
}
const fileStream = createWriteStream(dest)
// @ts-expect-error: type mismatch
const readableNodeStream = Readable.fromWeb(response.body)
await pipeline(readableNodeStream, fileStream)
} catch (error) {
console.error(`Error downloading file from ${url}:`, error)
process.exit(1)
}
}
async function cleanFontDirs(folderPath) {
await rm(DESIGN_SYSTEM_FONT_DIR, { recursive: true, force: true })
await mkdir(DESIGN_SYSTEM_FONT_DIR, { recursive: true })
await rm(WEB_FONT_DIR, { recursive: true, force: true })
await mkdir(WEB_FONT_DIR, { recursive: true })
await rm(SAS_FONT_DIR, { recursive: true, force: true })
await mkdir(SAS_FONT_DIR, { recursive: true })
}
async function updateFontCSS() {
const file = './packages/design-system/lib/fonts.css'
const css = await readFile(file, {
encoding: 'utf-8',
})
await writeFile(
file,
css.replace(
/url\(\/_static\/fonts\/material-symbols\/rounded[^)]+\)/,
`url(/_static/fonts/material-symbols/rounded-${hash}.woff2)`
),
{
encoding: 'utf-8',
}
)
}
async function main() {
const fontUrl = `${FONT_BASE_URL}&icon_names=${icons.join(',')}&display=block`
const iconUrl = await fetchIconUrl(fontUrl)
if (iconUrl) {
await cleanFontDirs()
await download(iconUrl, DESIGN_SYSTEM_FONT_DIR)
await download(iconUrl, WEB_FONT_DIR)
await download(iconUrl, SAS_FONT_DIR)
await updateFontCSS()
console.log('Successfully updated icons!')
process.exit(0)
} else {
console.error(`Unable to find the icon font src URL in CSS response from Google Fonts at ${fontUrl}`)
}
}
main()

View File

@@ -0,0 +1,344 @@
import crypto from "node:crypto";
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
import { createWriteStream } from "node:fs";
import { resolve, join } from "node:path";
import { Readable } from "node:stream";
import { pipeline } from "node:stream/promises";
import stringify from "json-stable-stringify-without-jsonify";
import { fileURLToPath } from "node:url";
const __filename = fileURLToPath(import.meta.url);
const __dirname = resolve(__filename, "..");
// Defines where the font lives
const FONT_DIR = resolve(__dirname, "../shared/fonts/material-symbols");
// Defines the settings for the font
const FONT_BASE_URL = `https://fonts.googleapis.com/css2?family=Material+Symbols+Rounded:opsz,wght,FILL,GRAD@24,400,0..1,0`;
// Defines the subset of icons for the font
const icons = [
"accessibility",
"accessible",
"add_circle",
"add",
"air_purifier_gen",
"air",
"airline_seat_recline_normal",
"airplane_ticket",
"apartment",
"apparel",
"arrow_back_ios",
"arrow_back",
"arrow_forward_ios",
"arrow_forward",
"arrow_right",
"arrow_upward",
"assistant_navigation",
"asterisk",
"attractions",
"award_star",
"bakery_dining",
"balcony",
"bathroom",
"bathtub",
"beach_access",
"bed",
"bedroom_parent",
"box",
"brunch_dining",
"business_center",
"calendar_add_on",
"calendar_clock",
"calendar_month",
"calendar_today",
"call_quality",
"call",
"camera",
"cancel",
"chair",
"charging_station",
"check_box",
"check_circle",
"check",
"checked_bag",
"checkroom",
"chevron_left",
"chevron_right",
"close",
"coffee_maker",
"coffee",
"compare_arrows",
"computer",
"concierge",
"confirmation_number",
"connected_tv",
"content_copy",
"contract",
"cool_to_dry",
"countertops",
"credit_card_heart",
"credit_card",
"curtains_closed",
"curtains",
"deck",
"delete",
"desk",
"device_thermostat",
"diamond",
"dining",
"directions_run",
"directions_subway",
"directions",
"downhill_skiing",
"download",
"dresser",
"edit_calendar",
"edit_square",
"edit",
"electric_bike",
"electric_car",
"elevator",
"emoji_transportation",
"error_circle_rounded",
"error",
"exercise",
"family_restroom",
"fastfood",
"favorite",
"fax",
"featured_seasonal_and_gifts",
"festival",
"filter_alt",
"filter",
"floor_lamp",
"forest",
"garage",
"globe",
"golf_course",
"groups",
"health_and_beauty",
"heat",
"hiking",
"home",
"hot_tub",
"houseboat",
"hvac",
"id_card",
"imagesmode",
"info",
"iron",
"kayaking",
"kettle",
"keyboard_arrow_down",
"keyboard_arrow_up",
"king_bed",
"kitchen",
"landscape",
"laundry",
"link",
"liquor",
"live_tv",
"local_bar",
"local_cafe",
"local_convenience_store",
"local_drink",
"local_laundry_service",
"local_parking",
"location_city",
"location_on",
"lock",
"loyalty",
"luggage",
"mail",
"map",
"meeting_room",
"microwave",
"mode_fan",
"museum",
"music_cast",
"music_note",
"nature",
"night_shelter",
"nightlife",
"open_in_new",
"pan_zoom",
"panorama",
"pedal_bike",
"person",
"pets",
"phone",
"photo_camera",
"pool",
"print",
"radio",
"recommend",
"redeem",
"refresh",
"remove",
"restaurant",
"room_service",
"router",
"sailing",
"sauna",
"scene",
"search",
"sell",
"shopping_bag",
"shower",
"single_bed",
"skateboarding",
"smoke_free",
"smoking_rooms",
"spa",
"sports_esports",
"sports_golf",
"sports_handball",
"sports_tennis",
"stairs",
"star",
"straighten",
"styler",
"support_agent",
"swipe",
"sync_saved_locally",
"table_bar",
"theater_comedy",
"things_to_do",
"train",
"tram",
"transit_ticket",
"travel_luggage_and_bags",
"travel",
"trophy",
"tv_guide",
"tv_remote",
"upload",
"visibility_off",
"visibility",
"ward",
"warning",
"water_full",
"wifi",
"yard",
].sort();
function createHash(value: unknown) {
const stringified = stringify(value);
const hash = crypto.createHash("sha256");
hash.update(stringified);
return hash.digest("hex");
}
const hash = createHash(icons).substring(0, 8);
async function fetchIconUrl(url: string) {
const response = await fetch(url, {
headers: {
Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7",
"User-Agent":
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36",
},
});
if (!response.ok) {
console.error(`Unable to fetch woff2 for ${url}`);
process.exit(1);
}
const text = await response.text();
const isWoff2 = /format\('woff2'\)/.test(text);
if (!isWoff2) {
console.error(`Unable to identify woff2 font in response`);
process.exit(1);
}
const srcUrl = text.match(/src: url\(([^)]+)\)/);
if (srcUrl && srcUrl[1]) {
return srcUrl[1];
}
return null;
}
async function download(url: string, destFolder: string) {
const dest = resolve(join(destFolder, `/rounded-${hash}.woff2`));
try {
const response = await fetch(url);
if (!response.ok) {
console.error(`Unable to fetch ${url}`);
process.exit(1);
}
if (!response.body) {
console.error(`Bad response from ${url}`);
process.exit(1);
}
const fileStream = createWriteStream(dest);
// @ts-expect-error: type mismatch
const readableNodeStream = Readable.fromWeb(response.body);
await pipeline(readableNodeStream, fileStream);
} catch (error) {
console.error(`Error downloading file from ${url}:`, error);
process.exit(1);
}
}
async function cleanFontDirs() {
await rm(FONT_DIR, { recursive: true, force: true });
await mkdir(FONT_DIR, { recursive: true });
await writeFile(
join(FONT_DIR, ".auto-generated"),
`Auto-generated, do not edit. Use scripts/material-symbols-update.mts to update.\nhash=${hash}\ncreated=${new Date().toISOString()}\n`,
{ encoding: "utf-8" }
);
}
async function updateFontCSS() {
const file = resolve(__dirname, "../packages/design-system/lib/fonts.css");
const css = await readFile(file, {
encoding: "utf-8",
});
await writeFile(
file,
css.replace(
/url\(\/_static\/shared\/fonts\/material-symbols\/rounded[^)]+\)/,
`url(/_static/shared/fonts/material-symbols/rounded-${hash}.woff2)`
),
{
encoding: "utf-8",
}
);
}
async function main() {
const fontUrl = `${FONT_BASE_URL}&icon_names=${icons.join(",")}&display=block`;
const iconUrl = await fetchIconUrl(fontUrl);
if (iconUrl) {
await cleanFontDirs();
await download(iconUrl, FONT_DIR);
await updateFontCSS();
console.log("Successfully updated icons!");
process.exit(0);
} else {
console.error(
`Unable to find the icon font src URL in CSS response from Google Fonts at ${fontUrl}`
);
}
}
main();