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('/>', 'fill="currentColor"/>'); 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 = {}; 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;