PronounsPage/nuxt.config.ts
Valentyne Stigloher 61ecf5025d (ts) migrate
2024-06-26 13:57:06 +02:00

657 lines
26 KiB
TypeScript

import './src/dotenv.ts';
import { loadSuml } from './server/loader.ts';
import autoprefixer from 'autoprefixer';
import fs from 'fs';
import path from 'path';
import rtlcss from 'rtlcss';
import type { NuxtConfig } from '@nuxt/types';
import type { IncomingMessage } from 'connect';
import type { ServerResponse } from 'http';
import type { Plugin as PostcssPlugin } from 'postcss';
import { buildList } from './src/helpers.ts';
import buildLocaleList from './src/buildLocaleList.ts';
import formatError from './src/error.ts';
import { tsvParseConfig } from './src/tsv.ts';
import type { Config } from './locale/config.ts';
import type { Translations } from './locale/translations.ts';
const config = loadSuml('config') as Config;
const translations = loadSuml('translations') as Translations;
const locale = config.locale;
const locales = buildLocaleList(locale);
const title = translations.title;
const description = translations.description;
const keywords = (translations?.seo?.keywords || []).join(', ');
const banner = `${process.env.BASE_URL}/api/banner/zaimki.png`;
const colour = '#C71585';
let __dirname = new URL('.', import.meta.url).pathname;
if (process.platform === 'win32') {
// A small hack, for Windows can't have nice things.
__dirname = __dirname.slice(1);
}
const logo = fs.readFileSync(`${__dirname}/static/logo/logo.svg`).toString('utf-8')
.replace('/></svg>', 'fill="currentColor"/></svg>');
const versionFile = `${__dirname}/cache/version`;
const version = fs.existsSync(versionFile) ? fs.readFileSync(versionFile).toString('utf-8') : null;
const applePrivateKeyFile = `${__dirname}/keys/AuthKey_${process.env.APPLE_KEY_ID}.p8`;
process.env.APPLE_PRIVATE_KEY = fs.existsSync(applePrivateKeyFile) ? fs.readFileSync(applePrivateKeyFile).toString('utf-8') : '';
const allVersionsUrls = buildList(function*() {
if (process.env.NODE_ENV === 'development') {
yield process.env.BASE_URL;
yield 'http://pronouns.test:3000';
yield 'http://localhost:3000';
} else if (process.env.ENV === 'test') {
yield process.env.BASE_URL;
} else {
yield 'https://pronouns.page';
for (const localeDescription of Object.values(locales)) {
yield localeDescription.url;
}
}
});
process.env.ALL_LOCALES_URLS = allVersionsUrls.join(',');
const postCssPlugins: PostcssPlugin[] = [
autoprefixer(),
];
if (config.dir === 'rtl') {
postCssPlugins.push(rtlcss() as PostcssPlugin);
}
const getAllFiles = (dirPath: string, arrayOfFiles: string[] = []): string[] => {
fs.readdirSync(dirPath).forEach(function (file) {
if (fs.statSync(`${dirPath}/${file}`).isDirectory()) {
arrayOfFiles = getAllFiles(`${dirPath}/${file}`, arrayOfFiles);
} else {
arrayOfFiles.push(path.join(dirPath, '/', file));
}
});
return arrayOfFiles;
};
const jsons: Record<string, unknown> = {};
for (const file of getAllFiles(`${__dirname}/data/docs`)) {
if (!file.endsWith('.json')) {
continue;
}
jsons[path.relative(`${__dirname}/data/docs`, file)] = JSON.parse(fs.readFileSync(file).toString());
}
const hostname = process.env.HOST ?? '0.0.0.0';
const port = parseInt(process.env.PORT ?? '3000');
const nuxtConfig: NuxtConfig = {
server: {
host: hostname, // listen on any host name
port,
},
target: 'server',
head: {
htmlAttrs: {
dir: config.dir || 'ltr',
},
title,
meta: [
{ charset: 'utf-8' },
{ hid: 'description', name: 'description', content: description },
{ hid: 'keywords', name: 'keywords', content: keywords },
{ name: 'viewport', content: 'width=device-width, initial-scale=1' },
{ hid: 'apple-mobile-web-app-title', name: 'apple-mobile-web-app-title', content: title },
{ hid: 'theme-color', name: 'theme-color', content: colour },
{ hid: 'og:type', property: 'og:type', content: 'article' },
{ hid: 'og:title', property: 'og:title', content: title },
{ hid: 'og:description', property: 'og:description', content: description },
{ hid: 'og:site_name', property: 'og:site_name', content: title },
{ hid: 'og:image', property: 'og:image', content: banner },
{ hid: 'twitter:card', property: 'twitter:card', content: 'summary_large_image' },
{ hid: 'twitter:title', property: 'twitter:title', content: title },
{ hid: 'twitter:description', property: 'twitter:description', content: description },
{ hid: 'twitter:site', property: 'twitter:site', content: process.env.BASE_URL },
{ hid: 'twitter:image', property: 'twitter:image', content: banner },
],
link: [
{ rel: 'icon', type: 'image/svg', href: '/logo/logo-primary.svg' },
],
},
css: [
'~/assets/style.scss',
],
plugins: [
{ src: '~/plugins/polyfill.ts', mode: 'client' },
{ src: '~/plugins/axios.ts' },
{ src: '~/plugins/globals.ts' },
{ src: '~/plugins/auth.ts' },
{ src: '~/plugins/track.ts', mode: 'client' },
{ src: '~/plugins/browserDetect.ts' },
],
components: true,
buildModules: [
'@nuxt/typescript-build',
],
typescript: {
typeCheck: process.env.NODE_ENV !== 'production',
},
modules: [
'@privyid/nuxt-csrf',
'@nuxtjs/pwa',
'@nuxtjs/axios',
['@nuxtjs/redirect-module', {
rules: config.redirects,
}],
'@nuxtjs/sentry',
'cookie-universal-nuxt',
'vue-plausible',
],
pwa: {
manifest: {
name: title,
short_name: title,
description,
background_color: '#ffffff',
theme_color: colour,
lang: locale,
},
workbox: {
runtimeCaching: [
{
urlPattern: /^\/@/,
handler: 'NetworkFirst',
},
],
},
},
plausible: {
domain: process.env.PLAUSIBLE_DOMAIN || translations.domain,
// NOTE(privacy): Disables automatic tracking of page views, meaning we have to do it manually
// If it's not done manually, a privacy issue occurs, which we *do not want*
// - tecc
enableAutoPageviews: false,
},
sentry: {
disabled: !process.env.SENTRY_DSN,
tracing: {
tracesSampleRate: 0.1,
browserTracing: {
enableInp: true,
},
},
publishRelease: {
telemetry: false,
},
config: {
environment: process.env.NODE_ENV === 'production' ? config.locale : process.env.NODE_ENV!,
attachStacktrace: true,
beforeSend(event) {
const denyUrls = [
'chrome-extension://',
'moz-extension://',
'webkit-masked-url://',
'https://s0.2mdn.net',
'https://j.adlooxtracking.com',
'https://c.amazon-adsystem.com',
'https://assets.a-mo.net',
'https://btloader.com',
'https://challenges.cloudflare.com',
'https://static.criteo.net',
'https://securepubads.g.doubleclick.net',
'https://cdn.flashtalking.com',
'https://ajs-assets.ftstatic.com',
'https://cdn.fuseplatform.net',
'https://cmp.inmobi.com',
'https://cdn.js7k.com',
'https://z.moatads.com',
'https://ced-ns.sascdn.com',
'https://a.teads.tv',
'https://s.yimg.com',
];
// filter out exceptions originating from third party
for (const exception of event.exception?.values || []) {
for (const frame of exception.stacktrace?.frames || []) {
const originatingFromThirdParty = denyUrls.some((denyUrl) => {
return frame.abs_path?.startsWith(denyUrl) || frame.filename?.startsWith(denyUrl);
});
if (originatingFromThirdParty) {
return null;
}
}
}
// do not send user information as Sentry somehow automatically detects username, email and user id
// https://docs.sentry.io/platforms/javascript/data-management/sensitive-data/
delete event.user;
return event;
},
beforeSendTransaction(event) {
// see comment on nuxtConfig.sentry.config.beforeSend
delete event.user;
return event;
},
},
},
publicRuntimeConfig: {
...config,
plausible: {
domain: process.env.PLAUSIBLE_DOMAIN || translations.domain,
apiHost: process.env.PLAUSIBLE_API_HOST,
enableAutoPageviews: false, // see comment on nuxtConfig.plausible.enableAutoPageviews
},
},
watch: ['data/config.suml'],
watchers: {
webpack: {
aggregateTimeout: 300,
poll: 1000,
},
},
build: {
postcss: {
postcssOptions: {
plugins: postCssPlugins,
},
},
optimization: {
splitChunks: {
maxSize: 250_000,
},
},
extend(config) {
config.module!.rules.push({
test: /\.csv|\.tsv$/,
loader: 'csv-loader',
options: tsvParseConfig,
});
config.module!.rules.push({
test: /\.suml$/,
loader: 'suml-loader',
});
config.module!.rules.push({
test: /\.md$/,
use: ['html-loader', 'markdown-loader'],
});
config.module!.rules.push({
test: /\.ya?ml$/,
use: 'yaml-loader',
});
config.module!.rules.push({
test: /\.js|\.ts$/,
loader: 'string-replace-loader',
options: {
// To load .json files inside of .js files of type module in a node environment,
// one has to either load from the filesystem or via a created require().
// While a load via filesystem is very unfriendly to webpack,
// the explicit creation of a require() function can be removed.
// This probably gets replaced in the future by a `import from with { type: 'json' }`
// statement, which is currently (2023-12) experimental in node and not well supported in webpack.
// https://nodejs.org/api/esm.html#json-modules
multiple: [
{
search: 'import { createRequire } from \'module\';',
replace: '',
},
{
search: /(?:const|var) require = createRequire\(import.meta.url\);/,
replace: '',
},
],
},
});
},
transpile: ['markdown-it'],
},
env: {
ENV: process.env.ENV!,
BASE_URL: process.env.BASE_URL!,
HOME_URL: process.env.HOME_URL || 'https://pronouns.page',
PUBLIC_KEY: fs.readFileSync(`${__dirname}/keys/public.pem`).toString(),
BUCKET: `https://${process.env.AWS_S3_BUCKET}.s3-${process.env.AWS_REGION}.amazonaws.com`,
CLOUDFRONT: process.env.CLOUDFRONT!,
TURNSTILE_SITEKEY: process.env.TURNSTILE_SITEKEY!,
ALL_LOCALES_URLS: process.env.ALL_LOCALES_URLS,
LOGO: logo,
JSONS: JSON.stringify(jsons),
PLAUSIBLE_API_HOST: process.env.PLAUSIBLE_API_HOST!,
HEARTBEAT_LINK: process.env.HEARTBEAT_LINK!,
VERSION: version!,
SHOPIFY_STOREFRONT_TOKEN: process.env.SHOPIFY_STOREFRONT_TOKEN ?? '',
},
serverMiddleware: [
'~/server/no-ssr.ts',
'~/server/index.ts',
],
axios: {
baseURL: `${process.env.BASE_URL}/api`,
},
router: {
extendRoutes(routes, resolve) {
if (config.pronouns.enabled) {
routes.push({
path: `/${encodeURIComponent(config.pronouns.route)}`,
component: resolve(__dirname, 'routes/pronouns.vue'),
});
}
if (config.sources.enabled) {
routes.push({
path: `/${encodeURIComponent(config.sources.route)}`,
component: resolve(__dirname, 'routes/sources.vue'),
});
}
if (config.nouns.enabled) {
routes.push({
path: `/${encodeURIComponent(config.nouns.route)}`,
component: resolve(__dirname, 'routes/nouns.vue'),
});
for (const subroute of config.nouns.subroutes || []) {
routes.push({
path: `/${encodeURIComponent(subroute)}`,
component: resolve(__dirname, `data/nouns/${subroute}.vue`),
});
}
if (config.nouns.templates.enabled && config.nouns.templates.route) {
routes.push({
path: `/${encodeURIComponent(config.nouns.templates.route)}`,
component: resolve(__dirname, 'routes/nounTemplates.vue'),
});
}
}
if (config.inclusive.enabled) {
routes.push({
path: `/${encodeURIComponent(config.inclusive.route)}`,
component: resolve(__dirname, 'routes/inclusive.vue'),
});
}
if (config.terminology.enabled) {
routes.push({
path: `/${encodeURIComponent(config.terminology.route)}`,
component: resolve(__dirname, 'routes/terminology.vue'),
});
if (config.nouns.enabled) {
// TODO remove later
routes.push({
path: `/${encodeURIComponent(config.nouns.route)}/${encodeURIComponent(config.terminology.route)}`,
component: resolve(__dirname, 'routes/terminology.vue'),
});
}
}
if (config.names.enabled) {
routes.push({
path: `/${encodeURIComponent(config.names.route)}`,
component: resolve(__dirname, 'routes/names.vue'),
});
}
if (config.faq.enabled) {
routes.push({
path: `/${encodeURIComponent(config.faq.route)}`,
component: resolve(__dirname, 'routes/faq.vue'),
});
}
if (config.links.enabled) {
routes.push({
path: `/${encodeURIComponent(config.links.route)}`,
component: resolve(__dirname, 'routes/links.vue'),
});
if (config.links.academicRoute) {
routes.push({
path: `/${encodeURIComponent(config.links.academicRoute)}`,
component: resolve(__dirname, 'routes/academic.vue'),
});
}
if (config.links.mediaRoute) {
routes.push({
path: `/${encodeURIComponent(config.links.mediaRoute)}`,
component: resolve(__dirname, 'routes/media.vue'),
});
}
if (config.links.translinguisticsRoute) {
routes.push({
path: `/${encodeURIComponent(config.links.translinguisticsRoute)}`,
component: resolve(__dirname, 'routes/translinguistics.vue'),
});
}
}
if (config.links.enabled && config.links.blog) {
routes.push({
path: `/${encodeURIComponent(config.links.blogRoute)}`,
component: resolve(__dirname, 'routes/blog.vue'),
name: 'blog',
});
routes.push({
path: `/${encodeURIComponent(config.links.blogRoute)}/:slug`,
component: resolve(__dirname, 'routes/blogEntry.vue'),
name: 'blogEntry',
});
if (config.blog && config.blog.shortcuts) {
for (const shortcut in config.blog.shortcuts) {
if (!config.blog.shortcuts.hasOwnProperty(shortcut)) {
continue;
}
const slug = config.blog.shortcuts[shortcut];
if ((config.blog.keepFullPath || []).includes(slug)) {
continue;
}
routes.push({
path: `/${encodeURIComponent(shortcut)}`,
component: resolve(__dirname, 'routes/blogEntry.vue'),
meta: { slug },
name: `blogEntryShortcut:${shortcut}`,
});
}
}
}
if (config.links.zine && config.links.zine.enabled) {
routes.push({
path: `/${encodeURIComponent(config.links.zine.route)}`,
component: resolve(__dirname, 'routes/zine.vue'),
});
}
if (config.people.enabled) {
routes.push({
path: `/${encodeURIComponent(config.people.route)}`,
component: resolve(__dirname, 'routes/people.vue'),
});
}
if (config.english.enabled) {
routes.push({
path: `/${encodeURIComponent(config.english.route)}`,
component: resolve(__dirname, 'routes/english.vue'),
});
}
if (config.contact.enabled) {
routes.push({
path: `/${encodeURIComponent(config.contact.route)}`,
component: resolve(__dirname, 'routes/contact.vue'),
});
if (config.contact.team.enabled) {
routes.push({
path: `/${encodeURIComponent(config.contact.team.route)}`,
component: resolve(__dirname, 'routes/team.vue'),
});
}
}
if (config.census.enabled) {
routes.push({
path: `/${encodeURIComponent(config.census.route)}`,
component: resolve(__dirname, 'routes/census.vue'),
});
routes.push({
path: `/${encodeURIComponent(config.census.route)}/admin`,
component: resolve(__dirname, 'routes/censusModeration.vue'),
});
}
if (config.user.enabled) {
routes.push({
path: `/${encodeURIComponent(config.user.route)}`,
component: resolve(__dirname, 'routes/user.vue'),
});
routes.push({
path: `/${encodeURIComponent(config.user.termsRoute)}`,
component: resolve(__dirname, 'routes/terms.vue'),
});
routes.push({
path: `/${encodeURIComponent(config.user.privacyRoute)}`,
component: resolve(__dirname, 'routes/privacy.vue'),
});
}
routes.push({ path: '/license', component: resolve(__dirname, 'routes/license.vue') });
routes.push({ path: '/design', component: resolve(__dirname, 'routes/design.vue') });
routes.push({ path: '/admin', component: resolve(__dirname, 'routes/admin.vue') });
routes.push({ path: '/admin/users', component: resolve(__dirname, 'routes/adminUsers.vue') });
routes.push({ path: '/admin/profiles', component: resolve(__dirname, 'routes/adminProfiles.vue') });
routes.push({
path: '/admin/audit-log/:username/:id',
component: resolve(__dirname, 'routes/adminAuditLog.vue'),
});
routes.push({
path: '/admin/timesheets',
component: resolve(__dirname, 'routes/adminTimesheets.vue'),
});
routes.push({
path: '/admin/timesheets/overview',
component: resolve(__dirname, 'routes/adminTimesheetsOverview.vue'),
});
routes.push({
path: '/admin/moderation',
component: resolve(__dirname, 'routes/adminModeration.vue'),
});
routes.push({
path: '/admin/abuse-reports',
component: resolve(__dirname, 'routes/adminAbuseReports.vue'),
});
routes.push({
path: '/admin/pending-bans',
component: resolve(__dirname, 'routes/adminPendingBans.vue'),
});
routes.push({
path: '/admin/translations/missing',
component: resolve(__dirname, 'routes/adminTranslationsMissing.vue'),
});
routes.push({
path: '/admin/translations/awaiting',
component: resolve(__dirname, 'routes/adminTranslationsAwaiting.vue'),
});
if (config.profile.enabled) {
routes.push({ path: '/u/*', component: resolve(__dirname, 'routes/profile.vue') });
routes.push({ path: '/@*', component: resolve(__dirname, 'routes/profile.vue') });
routes.push({ path: '/card/@*', component: resolve(__dirname, 'routes/profileCard.vue') });
if (config.profile.editorEnabled) {
routes.push({ path: '/editor', component: resolve(__dirname, 'routes/profileEditor.vue') });
}
}
if (config.pronouns.enabled) {
for (const prefix of [...config.pronouns?.sentence?.prefixes || [], config.pronouns?.prefix || '']) {
routes.push({
path: `${prefix}/${encodeURIComponent(config.pronouns.any)}`,
component: resolve(__dirname, 'routes/any.vue'),
});
routes.push({
path: `${prefix}/${encodeURIComponent(config.pronouns.any)}::group`,
component: resolve(__dirname, 'routes/any.vue'),
});
if (config.pronouns.null && config.pronouns.null.routes) {
for (const route of config.pronouns.null.routes) {
routes.push({
path: `${prefix}/${encodeURIComponent(route)}`,
component: resolve(__dirname, 'routes/avoiding.vue'),
});
}
}
if (config.pronouns.mirror) {
routes.push({
path: `${prefix}/${encodeURIComponent(config.pronouns.mirror.route)}`,
component: resolve(__dirname, 'routes/mirror.vue'),
});
}
}
}
if (config.calendar && config.calendar.enabled) {
routes.push({
path: `/${encodeURIComponent(config.calendar.route)}`,
component: resolve(__dirname, 'routes/calendar.vue'),
});
routes.push({
path: `/${encodeURIComponent(config.calendar.route)}/:year(\\d\\d\\d\\d)`,
component: resolve(__dirname, 'routes/calendar.vue'),
});
routes.push({
path: `/${encodeURIComponent(config.calendar.route)}/:year(\\d\\d\\d\\d)-:month(\\d\\d)-:day(\\d\\d)`,
component: resolve(__dirname, 'routes/calendarDay.vue'),
});
}
if (config.workshops && config.workshops.enabled) {
routes.push({
path: `/${encodeURIComponent(config.workshops.route)}`,
component: resolve(__dirname, 'routes/workshops.vue'),
});
}
if (config.api !== null) {
routes.push({ path: '/api', component: resolve(__dirname, 'routes/api.vue') });
}
routes.push({ name: 'all', path: '*', component: resolve(__dirname, 'routes/pronoun.vue') });
},
middleware: 'atom',
},
hooks: {
render: {
errorMiddleware(app) {
app.use((err: Error, req: IncomingMessage, res: ServerResponse, next: (err?: unknown) => void) => {
if (err) {
console.error(formatError(err, req));
}
next(err);
});
},
},
listen(_server, { port }) {
if (version) {
process.stderr.write(`[${new Date().toISOString()}] ` +
`Listening on port ${port} with version ${version}\n`);
}
},
},
loadingIndicator: {
name: 'views/loading.html',
},
};
export default nuxtConfig;