This commit is contained in:
@@ -0,0 +1,26 @@
|
||||
# http://editorconfig.org
|
||||
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
indent_style = space
|
||||
indent_size = 4
|
||||
end_of_line = lf
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[*.hbs]
|
||||
insert_final_newline = false
|
||||
|
||||
[{package,bower}.json]
|
||||
indent_size = 2
|
||||
|
||||
[*.{diff,md}]
|
||||
trim_trailing_whitespace = false
|
||||
|
||||
[*.yml]
|
||||
indent_size = 2
|
||||
|
||||
[Makefile]
|
||||
indent_style = tab
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"component-structure": "flat",
|
||||
"component-class": "@glimmer/component",
|
||||
/**
|
||||
Ember CLI sends analytics information by default. The data is completely
|
||||
anonymous, but there are times when you might want to disable this behavior.
|
||||
|
||||
Setting `disableAnalytics` to true will prevent any data from being sent.
|
||||
*/
|
||||
"disableAnalytics": true
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
# unconventional js
|
||||
/blueprints/*/files/
|
||||
/vendor/
|
||||
|
||||
# compiled output
|
||||
/dist/
|
||||
/tmp/
|
||||
|
||||
# dependencies
|
||||
/bower_components/
|
||||
/node_modules/
|
||||
|
||||
# misc
|
||||
/coverage/
|
||||
.eslintcache
|
||||
|
||||
# ember-try
|
||||
/.node_modules.ember-try/
|
||||
/bower.json.ember-try
|
||||
/package.json.ember-try
|
||||
@@ -0,0 +1,70 @@
|
||||
/* eslint-env node */
|
||||
module.exports = {
|
||||
root: true,
|
||||
parser: '@babel/eslint-parser',
|
||||
parserOptions: {
|
||||
sourceType: 'module',
|
||||
allowImportExportEverywhere: false,
|
||||
ecmaFeatures: {
|
||||
globalReturn: false,
|
||||
legacyDecorators: true,
|
||||
jsx: true
|
||||
},
|
||||
requireConfigFile: false,
|
||||
babelOptions: {
|
||||
plugins: [
|
||||
'@babel/plugin-proposal-class-properties',
|
||||
['@babel/plugin-proposal-decorators', {legacy: true}],
|
||||
'babel-plugin-transform-react-jsx'
|
||||
]
|
||||
}
|
||||
},
|
||||
plugins: [
|
||||
'ghost',
|
||||
'react'
|
||||
],
|
||||
extends: [
|
||||
'plugin:ghost/ember'
|
||||
],
|
||||
rules: {
|
||||
'ghost/filenames/match-exported-class': ['off'],
|
||||
// Enforce kebab-case (lowercase with hyphens) for all filenames
|
||||
'ghost/filenames/match-regex': ['error', '^[a-z0-9.-]+$', false],
|
||||
'no-shadow': ['error'],
|
||||
|
||||
// TODO: migrate away from accessing controller in routes
|
||||
'ghost/ember/no-controller-access-in-routes': 'off',
|
||||
|
||||
// TODO: enable once we're fully on octane 🏎
|
||||
'ghost/ember/no-assignment-of-untracked-properties-used-in-tracking-contexts': 'off',
|
||||
'ghost/ember/no-actions-hash': 'off',
|
||||
'ghost/ember/no-classic-classes': 'off',
|
||||
'ghost/ember/no-classic-components': 'off',
|
||||
'ghost/ember/require-tagless-components': 'off',
|
||||
'ghost/ember/no-component-lifecycle-hooks': 'off',
|
||||
|
||||
// disable linting of `this.get` until there's a reliable autofix
|
||||
'ghost/ember/use-ember-get-and-set': 'off',
|
||||
|
||||
// disable linting of mixins until we migrate away
|
||||
'ghost/ember/no-mixins': 'off',
|
||||
'ghost/ember/no-new-mixins': 'off',
|
||||
|
||||
'react/jsx-uses-react': 'error',
|
||||
'react/jsx-uses-vars': 'error'
|
||||
},
|
||||
overrides: [{
|
||||
files: 'tests/**/*.js',
|
||||
env: {
|
||||
embertest: true,
|
||||
mocha: true
|
||||
},
|
||||
extends: [
|
||||
'plugin:ghost/test'
|
||||
],
|
||||
rules: {
|
||||
'ghost/ember/no-invalid-debug-function-arguments': 'off',
|
||||
'ghost/mocha/no-setup-in-describe': 'off'
|
||||
}
|
||||
}]
|
||||
};
|
||||
@@ -0,0 +1,169 @@
|
||||
add|ember-template-lint|no-action|5|11|5|11|8eaebb48eca1563c6e0b18581df84ab59188d971|1746489600000|||app/components/gh-cm-editor.hbs
|
||||
add|ember-template-lint|no-passed-in-event-handlers|5|4|5|4|3a763e253744b070633bb8bd424b6c8e55f6b20a|1746489600000|||app/components/gh-cm-editor.hbs
|
||||
add|ember-template-lint|no-invalid-interactive|1|103|1|103|534029ab0ba1b74eff4a2f31c8b4dd9f1460316a|1746489600000|||app/components/gh-context-menu.hbs
|
||||
add|ember-template-lint|no-invalid-interactive|5|53|5|53|9647ef6afba919b2af04fe551b0fdf0fb63be849|1746489600000|||app/components/gh-context-menu.hbs
|
||||
add|ember-template-lint|no-action|5|18|5|18|0c80a75b2a80d404755333991c266c81c97c9cda|1746489600000|||app/components/gh-date-time-picker.hbs
|
||||
add|ember-template-lint|no-action|9|19|9|19|d12bcf1144bfb2fe70e7ab0f66836f1c6207a589|1746489600000|||app/components/gh-editor.hbs
|
||||
add|ember-template-lint|no-action|10|20|10|20|16d94650de2ffbe8ee3f2ce3ba5ca97a6304b739|1746489600000|||app/components/gh-editor.hbs
|
||||
add|ember-template-lint|no-action|11|17|11|17|30d64b1bf8990e2f84c52665690739fcc726e9f7|1746489600000|||app/components/gh-editor.hbs
|
||||
add|ember-template-lint|no-action|2|45|2|45|55b33496610b2f4ef6b9fe62713f4b4ddee37f19|1746489600000|||app/components/gh-fullscreen-modal.hbs
|
||||
add|ember-template-lint|no-action|10|29|10|29|ebbd89a393bcec7f537f2104ace2a6b1941a19a7|1746489600000|||app/components/gh-fullscreen-modal.hbs
|
||||
add|ember-template-lint|no-action|11|32|11|32|ab89b6f10c519be1271386203e9439d261eecd67|1746489600000|||app/components/gh-fullscreen-modal.hbs
|
||||
add|ember-template-lint|no-action|13|36|13|36|3776877637b49b65deef537c68ae490b2fb081a9|1746489600000|||app/components/gh-fullscreen-modal.hbs
|
||||
add|ember-template-lint|no-invalid-interactive|2|45|2|45|918fdec8490009c6197091e319d6ec52ee95b983|1746489600000|||app/components/gh-fullscreen-modal.hbs
|
||||
add|ember-template-lint|link-href-attributes|8|8|8|8|2078c6e43d1e5548ae745b7ac8cb736f7d2aaf68|1746489600000|||app/components/gh-image-uploader-with-preview.hbs
|
||||
add|ember-template-lint|no-invalid-interactive|8|60|8|60|2078c6e43d1e5548ae745b7ac8cb736f7d2aaf68|1746489600000|||app/components/gh-image-uploader-with-preview.hbs
|
||||
add|ember-template-lint|require-valid-alt-text|3|13|3|13|079fc89fa5c7c47f6b0b219820cdda3819c44e26|1746489600000|||app/components/gh-image-uploader-with-preview.hbs
|
||||
add|ember-template-lint|no-action|12|58|12|58|031d04576149d3034549aa3d14bc26da704cd70c|1746489600000|||app/components/gh-image-uploader.hbs
|
||||
add|ember-template-lint|no-action|17|75|17|75|bbc5d9a459cd07e56d8c79dde619775b89d7cc89|1746489600000|||app/components/gh-image-uploader.hbs
|
||||
add|ember-template-lint|no-action|22|52|22|52|b1bd53a513ad82434d5a0a9a96441a45d416d7ad|1746489600000|||app/components/gh-image-uploader.hbs
|
||||
add|ember-template-lint|no-action|31|16|31|16|4c64ddeaf795ee831d0d7346667a305814ca9855|1746489600000|||app/components/gh-image-uploader.hbs
|
||||
add|ember-template-lint|no-action|32|15|32|15|b1bd53a513ad82434d5a0a9a96441a45d416d7ad|1746489600000|||app/components/gh-image-uploader.hbs
|
||||
add|ember-template-lint|no-invalid-interactive|22|52|22|52|74dea739bef284d6557987ff3da53fa1278030e2|1746489600000|||app/components/gh-image-uploader.hbs
|
||||
add|ember-template-lint|no-action|10|16|10|16|8d8dd8c2cb5f9910c2de5ca6acc76ee4262a876e|1746489600000|||app/components/gh-members-import-mapping-input.hbs
|
||||
add|ember-template-lint|no-action|9|36|9|36|05358b6ec6e9afbaa47416266c49f40a3ffb4490|1746489600000|||app/components/gh-members-import-table.hbs
|
||||
add|ember-template-lint|no-action|10|36|10|36|c5ce93cf577ec47970f715ed83ed11a63adb7a63|1746489600000|||app/components/gh-members-import-table.hbs
|
||||
add|ember-template-lint|no-redundant-role|6|20|6|20|bc4fbabe3d468440d8a2c70e92cec991caca41e2|1746489600000|||app/components/gh-post-bookmark.hbs
|
||||
add|ember-template-lint|no-redundant-role|14|64|14|64|ce988c0098c6e4845fc3307eb523b8b1687a3ecf|1746489600000|||app/components/gh-post-bookmark.hbs
|
||||
add|ember-template-lint|no-action|36|43|36|43|66cebfc8448eced0bccf3faa57b4af0cd633e65e|1746489600000|||app/components/gh-post-settings-menu.hbs
|
||||
add|ember-template-lint|no-action|37|47|37|47|70f3e9aa9a9aa52142c4e0c015a8f173d12b05ed|1746489600000|||app/components/gh-post-settings-menu.hbs
|
||||
add|ember-template-lint|no-action|56|41|56|41|43cc094c1a6b50ecd24c8af325dfdfcac863bb14|1746489600000|||app/components/gh-post-settings-menu.hbs
|
||||
add|ember-template-lint|no-action|57|41|57|41|e7f429eefd04a55d4ef1875fe601da1b69964364|1746489600000|||app/components/gh-post-settings-menu.hbs
|
||||
add|ember-template-lint|no-action|87|50|87|50|0e827c1770073f988535ceb9d6c49808a153d896|1746489600000|||app/components/gh-post-settings-menu.hbs
|
||||
add|ember-template-lint|no-action|104|39|104|39|5cbc9d2abf29e108bfc207579df1206485e2a74d|1746489600000|||app/components/gh-post-settings-menu.hbs
|
||||
add|ember-template-lint|no-action|105|43|105|43|1f2dd4961e9757ede9706bb0aa9b8baf9c49b22b|1746489600000|||app/components/gh-post-settings-menu.hbs
|
||||
add|ember-template-lint|no-action|116|132|116|132|90dbb84741c72aa86a601e1cb95c066bd53898bd|1746489600000|||app/components/gh-post-settings-menu.hbs
|
||||
add|ember-template-lint|no-action|123|46|123|46|56ca074eb0236b8becc4084423056a8ffa4453e9|1746489600000|||app/components/gh-post-settings-menu.hbs
|
||||
add|ember-template-lint|no-action|158|73|158|73|b07bf9d50af5f00861571521b150cf6504b90704|1746489600000|||app/components/gh-post-settings-menu.hbs
|
||||
add|ember-template-lint|no-action|172|56|172|56|38755451c044c56040684339301c21b2aab49ecb|1746489600000|||app/components/gh-post-settings-menu.hbs
|
||||
add|ember-template-lint|no-action|190|50|190|50|9f1c3c88187e5db774f97704269e372f8331eba5|1746489600000|||app/components/gh-post-settings-menu.hbs
|
||||
add|ember-template-lint|no-action|196|50|196|50|97acfb2045b33a97621258596b631362ad4c56d5|1746489600000|||app/components/gh-post-settings-menu.hbs
|
||||
add|ember-template-lint|no-action|202|51|202|51|358336432fcb413c522c5f1ff3062a68ef9f449f|1746489600000|||app/components/gh-post-settings-menu.hbs
|
||||
add|ember-template-lint|no-action|208|50|208|50|78a45cbb7eafdda134d96f6035b0026f339e75ad|1746489600000|||app/components/gh-post-settings-menu.hbs
|
||||
add|ember-template-lint|no-action|214|50|214|50|693211baf08c011e77284b483c30e28ecaf63520|1746489600000|||app/components/gh-post-settings-menu.hbs
|
||||
add|ember-template-lint|no-action|223|105|223|105|de5b68b49193c72f85c0e478f151a00180321d91|1746489600000|||app/components/gh-post-settings-menu.hbs
|
||||
add|ember-template-lint|no-action|236|167|236|167|0145b67f0faef0aad141c6a4269c35c6ef8f0a47|1746489600000|||app/components/gh-post-settings-menu.hbs
|
||||
add|ember-template-lint|no-action|601|163|601|163|0145b67f0faef0aad141c6a4269c35c6ef8f0a47|1746489600000|||app/components/gh-post-settings-menu.hbs
|
||||
add|ember-template-lint|no-action|607|34|607|34|3d44a2ad21d60d18bed6f79265caa4887361aaa4|1746489600000|||app/components/gh-post-settings-menu.hbs
|
||||
add|ember-template-lint|no-action|616|47|616|47|67551960e57b2ad06d24289e0d6f256dc5635cae|1746489600000|||app/components/gh-post-settings-menu.hbs
|
||||
add|ember-template-lint|no-action|617|51|617|51|50e3029f4c845049ff10130cf0dc2ea06c7a3d2d|1746489600000|||app/components/gh-post-settings-menu.hbs
|
||||
add|ember-template-lint|no-action|632|47|632|47|0d852775b4028d61c2c91b5d821c79de3cdbd1de|1746489600000|||app/components/gh-post-settings-menu.hbs
|
||||
add|ember-template-lint|no-action|633|51|633|51|194439ec4cb10e176eecb1f55c2995c00f6c67b4|1746489600000|||app/components/gh-post-settings-menu.hbs
|
||||
add|ember-template-lint|no-action|647|47|647|47|3be93048908ab43d74726c3983982fcb9a3570e3|1746489600000|||app/components/gh-post-settings-menu.hbs
|
||||
add|ember-template-lint|no-action|648|51|648|51|40871d259cc307345b8096e4215ae58e5886b724|1746489600000|||app/components/gh-post-settings-menu.hbs
|
||||
add|ember-template-lint|no-action|674|166|674|166|0145b67f0faef0aad141c6a4269c35c6ef8f0a47|1746489600000|||app/components/gh-post-settings-menu.hbs
|
||||
add|ember-template-lint|no-action|681|34|681|34|3d44a2ad21d60d18bed6f79265caa4887361aaa4|1746489600000|||app/components/gh-post-settings-menu.hbs
|
||||
add|ember-template-lint|no-action|686|44|686|44|d2abbb4bb55b6cb1131ca0bc56c31632b32b980c|1746489600000|||app/components/gh-post-settings-menu.hbs
|
||||
add|ember-template-lint|no-action|687|44|687|44|3ede83bd64d206d665edb4f1b73f03b4122edfad|1746489600000|||app/components/gh-post-settings-menu.hbs
|
||||
add|ember-template-lint|no-action|697|47|697|47|13b7064b520a924d9e57a4a680621ce979d23923|1746489600000|||app/components/gh-post-settings-menu.hbs
|
||||
add|ember-template-lint|no-action|698|51|698|51|b0fc38b818f4ca2613684588e4122e12da0090b9|1746489600000|||app/components/gh-post-settings-menu.hbs
|
||||
add|ember-template-lint|no-action|713|47|713|47|f34f084dc72c079be9ba3eb93c255bd8853612bd|1746489600000|||app/components/gh-post-settings-menu.hbs
|
||||
add|ember-template-lint|no-action|714|51|714|51|c5c09d001fa96a623649cc88ef13b3c6c164f05b|1746489600000|||app/components/gh-post-settings-menu.hbs
|
||||
add|ember-template-lint|no-action|742|167|742|167|0145b67f0faef0aad141c6a4269c35c6ef8f0a47|1746489600000|||app/components/gh-post-settings-menu.hbs
|
||||
add|ember-template-lint|no-action|748|34|748|34|3d44a2ad21d60d18bed6f79265caa4887361aaa4|1746489600000|||app/components/gh-post-settings-menu.hbs
|
||||
add|ember-template-lint|no-action|753|44|753|44|32101c0834d9e8d2caa87dee9cb1a92fe633cfde|1746489600000|||app/components/gh-post-settings-menu.hbs
|
||||
add|ember-template-lint|no-action|754|44|754|44|c8a82286cfe4220cb2d8a059b1daf7f6c8d54cf5|1746489600000|||app/components/gh-post-settings-menu.hbs
|
||||
add|ember-template-lint|no-action|764|47|764|47|31800fb83f1797de5d91b7007c48432cf2fae803|1746489600000|||app/components/gh-post-settings-menu.hbs
|
||||
add|ember-template-lint|no-action|765|51|765|51|5ce0788874489341385f031810e9f7f62724aaf8|1746489600000|||app/components/gh-post-settings-menu.hbs
|
||||
add|ember-template-lint|no-action|778|47|778|47|1cbd7d922a9d202772a9dca214d97bed18777d6e|1746489600000|||app/components/gh-post-settings-menu.hbs
|
||||
add|ember-template-lint|no-action|779|51|779|51|16fb2c7c87dc282c6ee49a032aaaf22628b88084|1746489600000|||app/components/gh-post-settings-menu.hbs
|
||||
add|ember-template-lint|no-action|810|168|810|168|0145b67f0faef0aad141c6a4269c35c6ef8f0a47|1746489600000|||app/components/gh-post-settings-menu.hbs
|
||||
add|ember-template-lint|no-action|816|34|816|34|3d44a2ad21d60d18bed6f79265caa4887361aaa4|1746489600000|||app/components/gh-post-settings-menu.hbs
|
||||
add|ember-template-lint|no-action|823|50|823|50|d03e7bbf2b6de94b08fae1c33bd5a2f16874e03a|1746489600000|||app/components/gh-post-settings-menu.hbs
|
||||
add|ember-template-lint|no-action|825|48|825|48|bca167f0250b40d56ff1ed40ec21bc88f8e27ec3|1746489600000|||app/components/gh-post-settings-menu.hbs
|
||||
add|ember-template-lint|no-action|836|50|836|50|93ae447fb1054de4dbe501fc60167a6ff3273687|1746489600000|||app/components/gh-post-settings-menu.hbs
|
||||
add|ember-template-lint|no-action|838|48|838|48|ecb9ed2577be7ea0300563f22d8e9fde2fed12a6|1746489600000|||app/components/gh-post-settings-menu.hbs
|
||||
add|ember-template-lint|no-passed-in-event-handlers|36|36|36|36|fed655605208b290a18b9f7da51a6cf7b40c0e9a|1746489600000|||app/components/gh-post-settings-menu.hbs
|
||||
add|ember-template-lint|no-passed-in-event-handlers|104|32|104|32|187e85e14470b453a1e6df8c5f18549d11c441a0|1746489600000|||app/components/gh-post-settings-menu.hbs
|
||||
add|ember-template-lint|no-passed-in-event-handlers|616|40|616|40|f290a04fd56f613d80d61244d161631f630185a8|1746489600000|||app/components/gh-post-settings-menu.hbs
|
||||
add|ember-template-lint|no-passed-in-event-handlers|632|40|632|40|c1af88109f705acc93fab4ee7b4d89096136ffe1|1746489600000|||app/components/gh-post-settings-menu.hbs
|
||||
add|ember-template-lint|no-passed-in-event-handlers|647|40|647|40|852cbb2c63e19a28d700d3b18e6f887edef303fa|1746489600000|||app/components/gh-post-settings-menu.hbs
|
||||
add|ember-template-lint|no-passed-in-event-handlers|697|40|697|40|58185c92e8b6261ea1483a70296d9fa837d3f2f5|1746489600000|||app/components/gh-post-settings-menu.hbs
|
||||
add|ember-template-lint|no-passed-in-event-handlers|713|40|713|40|c4353acd715c396564c69389edd0a52246d3b966|1746489600000|||app/components/gh-post-settings-menu.hbs
|
||||
add|ember-template-lint|no-passed-in-event-handlers|764|40|764|40|29f26e46559dc40d9724a05b7516cb52f1481aaa|1746489600000|||app/components/gh-post-settings-menu.hbs
|
||||
add|ember-template-lint|no-passed-in-event-handlers|778|40|778|40|42e0617f832585eaa056c9c66dffe1a126318faf|1746489600000|||app/components/gh-post-settings-menu.hbs
|
||||
add|ember-template-lint|no-passed-in-event-handlers|823|40|823|40|d1dccfeee5b103fac3b14d5b4567bc65ee08fd5a|1746489600000|||app/components/gh-post-settings-menu.hbs
|
||||
add|ember-template-lint|no-passed-in-event-handlers|836|40|836|40|055c4b70aa8daba6b97077eefa95ebed7f7f5315|1746489600000|||app/components/gh-post-settings-menu.hbs
|
||||
add|ember-template-lint|no-action|4|14|4|14|c8f6146f9286ec1b289e28983ab446386f390f01|1746489600000|||app/components/gh-psm-authors-input.hbs
|
||||
add|ember-template-lint|no-action|9|24|9|24|2e612be5e2b8e0b792f951c3c75b2f71df880365|1746489600000|||app/components/gh-psm-template-select.hbs
|
||||
add|ember-template-lint|no-action|7|16|7|16|86819b20fcc42bc1d4607519c1d45532ee337884|1746489600000|||app/components/gh-psm-visibility-input.hbs
|
||||
add|ember-template-lint|no-autofocus-attribute|13|16|13|16|fa0ffb960072633b72117849e3927673be0059af|1746489600000|||app/components/gh-search-input.hbs
|
||||
add|ember-template-lint|require-iframe-title|1|0|1|0|956ab219134ac63aec3fd2d35582076c21631b75|1746489600000|||app/components/gh-site-iframe.hbs
|
||||
add|ember-template-lint|no-yield-only|1|0|1|0|a5fa6e8c1e0f03fb31b5cd17770e4368d44932e4|1746489600000|||app/components/gh-text-input.hbs
|
||||
add|ember-template-lint|no-action|1|51|1|51|674a942db62e014b25156b02d014f6c63b2f1cb2|1746489600000|||app/components/gh-theme-error-li.hbs
|
||||
add|ember-template-lint|no-action|2|11|2|11|2b1317e72b94ec31bf2700acc3f041e6be974b72|1746489600000|||app/components/gh-uploader.hbs
|
||||
add|ember-template-lint|no-action|7|13|7|13|add4b57d48167f809045245535d45bedb00cb753|1746489600000|||app/components/gh-uploader.hbs
|
||||
add|ember-template-lint|no-action|8|22|8|22|ce1eba2b791c0f120baf10871fd3949c1b9f6a79|1746489600000|||app/components/gh-uploader.hbs
|
||||
add|ember-template-lint|no-action|9|22|9|22|0209f233628ec084b87894b4be9073c29f2a4f47|1746489600000|||app/components/gh-uploader.hbs
|
||||
add|ember-template-lint|no-passed-in-event-handlers|4|4|4|4|3dedc53191768d81765eac4a7a049a9aba7df442|1746489600000|||app/components/gh-url-input.hbs
|
||||
add|ember-template-lint|no-action|1|71|1|71|2e6351f546807d88cc8eb9dbe8baa149468b5cb9|1746489600000|||app/components/gh-view-title.hbs
|
||||
add|ember-template-lint|no-invalid-role|1|0|1|0|3e651d38e0110e1be20e5082075db1b879b59a36|1746489600000|||app/components/gh-view-title.hbs
|
||||
add|ember-template-lint|no-action|5|50|5|50|141d456b03124abca146e58e4ae15825fdd040bb|1746489600000|||app/components/modal-impersonate-member.hbs
|
||||
add|ember-template-lint|no-action|5|74|5|74|d465b362b15b90cf42a093e72895155f49cdf6f2|1746489600000|||app/components/modal-impersonate-member.hbs
|
||||
add|ember-template-lint|no-action|43|57|43|57|141d456b03124abca146e58e4ae15825fdd040bb|1746489600000|||app/components/modal-import-members.hbs
|
||||
add|ember-template-lint|no-action|50|56|50|56|190532b9954beb4e7e9e0f1c51d170e64d4a5315|1746489600000|||app/components/modal-import-members.hbs
|
||||
add|ember-template-lint|no-action|56|34|56|34|ff609af61e9159dfc5386543d559f6d217d39531|1746489600000|||app/components/modal-import-members.hbs
|
||||
add|ember-template-lint|no-action|57|29|57|29|9a0a72738b6e1a4f46415b2328c37d1814562717|1746489600000|||app/components/modal-import-members.hbs
|
||||
add|ember-template-lint|no-action|110|89|110|89|141d456b03124abca146e58e4ae15825fdd040bb|1746489600000|||app/components/modal-import-members.hbs
|
||||
add|ember-template-lint|no-action|116|91|116|91|031d04576149d3034549aa3d14bc26da704cd70c|1746489600000|||app/components/modal-import-members.hbs
|
||||
add|ember-template-lint|no-action|119|116|119|116|7e581bf2ffd5254ae851201e1f23cb9eaa9b198b|1746489600000|||app/components/modal-import-members.hbs
|
||||
add|ember-template-lint|no-action|129|120|129|120|031d04576149d3034549aa3d14bc26da704cd70c|1746489600000|||app/components/modal-import-members.hbs
|
||||
add|ember-template-lint|no-action|132|103|132|103|7e581bf2ffd5254ae851201e1f23cb9eaa9b198b|1746489600000|||app/components/modal-import-members.hbs
|
||||
add|ember-template-lint|no-action|143|112|143|112|031d04576149d3034549aa3d14bc26da704cd70c|1746489600000|||app/components/modal-import-members.hbs
|
||||
add|ember-template-lint|no-action|147|110|147|110|141d456b03124abca146e58e4ae15825fdd040bb|1746489600000|||app/components/modal-import-members.hbs
|
||||
add|ember-template-lint|no-action|153|97|153|97|141d456b03124abca146e58e4ae15825fdd040bb|1746489600000|||app/components/modal-import-members.hbs
|
||||
add|ember-template-lint|no-action|156|112|156|112|031d04576149d3034549aa3d14bc26da704cd70c|1746489600000|||app/components/modal-import-members.hbs
|
||||
add|ember-template-lint|no-action|160|99|160|99|031d04576149d3034549aa3d14bc26da704cd70c|1746489600000|||app/components/modal-import-members.hbs
|
||||
add|ember-template-lint|no-action|163|110|163|110|141d456b03124abca146e58e4ae15825fdd040bb|1746489600000|||app/components/modal-import-members.hbs
|
||||
add|ember-template-lint|no-action|171|91|171|91|031d04576149d3034549aa3d14bc26da704cd70c|1746489600000|||app/components/modal-import-members.hbs
|
||||
add|ember-template-lint|no-action|174|102|174|102|141d456b03124abca146e58e4ae15825fdd040bb|1746489600000|||app/components/modal-import-members.hbs
|
||||
add|ember-template-lint|no-action|181|95|181|95|031d04576149d3034549aa3d14bc26da704cd70c|1746489600000|||app/components/modal-import-members.hbs
|
||||
add|ember-template-lint|no-action|185|102|185|102|141d456b03124abca146e58e4ae15825fdd040bb|1746489600000|||app/components/modal-import-members.hbs
|
||||
add|ember-template-lint|no-invalid-interactive|28|24|28|24|42a29ae16e22270f0590c9ce5caa7bfec541ca0b|1746489600000|||app/components/modal-member-tier.hbs
|
||||
add|ember-template-lint|no-action|5|57|5|57|141d456b03124abca146e58e4ae15825fdd040bb|1746489600000|||app/components/modal-members-label-form.hbs
|
||||
add|ember-template-lint|no-action|14|45|14|45|141d456b03124abca146e58e4ae15825fdd040bb|1746489600000|||app/components/modal-members-label-form.hbs
|
||||
add|ember-template-lint|no-action|23|59|23|59|141d456b03124abca146e58e4ae15825fdd040bb|1746489600000|||app/components/modal-members-label-form.hbs
|
||||
add|ember-template-lint|no-action|23|83|23|83|d465b362b15b90cf42a093e72895155f49cdf6f2|1746489600000|||app/components/modal-members-label-form.hbs
|
||||
add|ember-template-lint|no-action|34|31|34|31|a8ad062b8379233b7970fe5ea74296fdf5011567|1746489600000|||app/components/modal-members-label-form.hbs
|
||||
add|ember-template-lint|no-action|47|82|47|82|141d456b03124abca146e58e4ae15825fdd040bb|1746489600000|||app/components/modal-members-label-form.hbs
|
||||
add|ember-template-lint|no-action|49|16|49|16|d465b362b15b90cf42a093e72895155f49cdf6f2|1746489600000|||app/components/modal-members-label-form.hbs
|
||||
add|ember-template-lint|no-action|55|113|55|113|c259009ff744c2e9f7bb273e0eb3a3879036ba85|1746489600000|||app/components/modal-members-label-form.hbs
|
||||
add|ember-template-lint|no-redundant-role|85|24|85|24|ce988c0098c6e4845fc3307eb523b8b1687a3ecf|1746489600000|||app/components/modal-post-success.hbs
|
||||
add|ember-template-lint|no-redundant-role|136|24|136|24|ce988c0098c6e4845fc3307eb523b8b1687a3ecf|1746489600000|||app/components/modal-post-success.hbs
|
||||
add|ember-template-lint|no-action|4|53|4|53|141d456b03124abca146e58e4ae15825fdd040bb|1746489600000|||app/components/modal-unsubscribe-members.hbs
|
||||
add|ember-template-lint|no-action|50|89|50|89|141d456b03124abca146e58e4ae15825fdd040bb|1746489600000|||app/components/modal-unsubscribe-members.hbs
|
||||
add|ember-template-lint|no-action|54|71|54|71|141d456b03124abca146e58e4ae15825fdd040bb|1746489600000|||app/components/modal-unsubscribe-members.hbs
|
||||
add|ember-template-lint|require-valid-alt-text|4|12|4|12|8369d1b06deac93e8c8e05444670c15182aea434|1746489600000|||app/templates/application-error.hbs
|
||||
add|ember-template-lint|no-redundant-role|91|40|91|40|9d2eded16257516b455504637aab4057b3c0c9ef|1746489600000|||app/templates/mentions.hbs
|
||||
add|ember-template-lint|no-redundant-role|113|20|113|20|0418319e2dce98b674dbb6657d185f465d21f87d|1746489600000|||app/templates/mentions.hbs
|
||||
add|ember-template-lint|no-action|20|31|20|31|3aa834e53af871a821ebb1b47f7a04f925c2b593|1746489600000|||app/templates/setup.hbs
|
||||
add|ember-template-lint|no-action|21|35|21|35|ae65e93ed2b79a307e0eba50b154fe84ee1ae210|1746489600000|||app/templates/setup.hbs
|
||||
add|ember-template-lint|no-action|37|31|37|31|eefdee43411bdd45c2786b9e826e6b550fd6212d|1746489600000|||app/templates/setup.hbs
|
||||
add|ember-template-lint|no-action|38|35|38|35|a8e4bd4c57a8f2df65749b0cfa4349da22b2a0aa|1746489600000|||app/templates/setup.hbs
|
||||
add|ember-template-lint|no-action|55|31|55|31|1709109776bf3fc47aaecc21e6d3ec8a0489ad6b|1746489600000|||app/templates/setup.hbs
|
||||
add|ember-template-lint|no-action|56|35|56|35|8000241a81189d3876d264331ec0c9b6476df076|1746489600000|||app/templates/setup.hbs
|
||||
add|ember-template-lint|no-action|73|31|73|31|6756e119daad4aa143724e9b56a6aef744d65251|1746489600000|||app/templates/setup.hbs
|
||||
add|ember-template-lint|no-action|74|35|74|35|26b1d89c0e5bbcd629a215903c0819d35f798aa6|1746489600000|||app/templates/setup.hbs
|
||||
add|ember-template-lint|no-passed-in-event-handlers|20|24|20|24|f0b7babba7593639d68dadae2f19e7144dd8c63e|1746489600000|||app/templates/setup.hbs
|
||||
add|ember-template-lint|no-passed-in-event-handlers|37|24|37|24|2692530760cb1f7156dcf9570aacf0f8baca2770|1746489600000|||app/templates/setup.hbs
|
||||
add|ember-template-lint|no-passed-in-event-handlers|55|24|55|24|ce0d7e2e732b22ce3643fb9b1ac6ecef07c275ff|1746489600000|||app/templates/setup.hbs
|
||||
add|ember-template-lint|no-passed-in-event-handlers|73|24|73|24|80681fcec2258c3d81a231bbd635aa1f03ff1452|1746489600000|||app/templates/setup.hbs
|
||||
add|ember-template-lint|no-action|6|108|6|108|ccc38f66549f9baedaa3b9943ae6634ea8f99e69|1746489600000|||app/templates/tags.hbs
|
||||
add|ember-template-lint|no-action|7|110|7|110|c3819ce2b6989e8596be570ed0c9fb82b5012521|1746489600000|||app/templates/tags.hbs
|
||||
add|ember-template-lint|require-valid-alt-text|15|32|15|32|80c1ce6724481312363dc4e1db42bf28b41909f2|1746489600000|||app/templates/whatsnew.hbs
|
||||
add|ember-template-lint|no-invalid-interactive|8|51|8|51|66a27dbed218d15e49e91c72a93215ad0d90f778|1746489600000|||app/components/gh-power-select/trigger.hbs
|
||||
add|ember-template-lint|no-yield-only|1|0|1|0|a5fa6e8c1e0f03fb31b5cd17770e4368d44932e4|1746489600000|||app/components/gh-token-input/label-token.hbs
|
||||
add|ember-template-lint|no-yield-only|1|0|1|0|a5fa6e8c1e0f03fb31b5cd17770e4368d44932e4|1746489600000|||app/components/gh-token-input/tag-token.hbs
|
||||
add|ember-template-lint|no-action|8|19|8|19|73ac7d3892fcbcf15c3d5c44fca14dd21016daea|1746489600000|||app/components/gh-token-input/trigger.hbs
|
||||
add|ember-template-lint|no-positive-tabindex|46|8|46|8|6118264a9a0599fab6ad4da7264c1bfffa88687e|1746489600000|||app/components/gh-token-input/trigger.hbs
|
||||
add|ember-template-lint|require-iframe-title|27|20|27|20|94e58d11848d5613900c2188ba63dac41f4c03bb|1746489600000|||app/components/modals/email-preview.hbs
|
||||
add|ember-template-lint|require-iframe-title|42|16|42|16|a3292b469dc37f2f4791e7f224b0b65c8ecf5d18|1746489600000|||app/components/modals/email-preview.hbs
|
||||
add|ember-template-lint|no-autofocus-attribute|21|20|21|20|942419d05c04ded6716f09faecd6b1ab55418121|1746489600000|||app/components/modals/new-custom-integration.hbs
|
||||
add|ember-template-lint|no-invalid-interactive|2|37|2|37|e21ba31f54b631a428c28a1c9f88d0dc66f2f5fc|1746489600000|||app/components/modals/search.hbs
|
||||
add|ember-template-lint|no-redundant-role|11|20|11|20|bc4fbabe3d468440d8a2c70e92cec991caca41e2|1746489600000|||app/components/dashboard/onboarding/share-modal.hbs
|
||||
remove|ember-template-lint|no-action|6|108|6|108|ccc38f66549f9baedaa3b9943ae6634ea8f99e69|1746489600000|||app/templates/tags.hbs
|
||||
remove|ember-template-lint|no-action|7|110|7|110|c3819ce2b6989e8596be570ed0c9fb82b5012521|1746489600000|||app/templates/tags.hbs
|
||||
remove|ember-template-lint|require-valid-alt-text|4|12|4|12|8369d1b06deac93e8c8e05444670c15182aea434|1746489600000|||app/templates/application-error.hbs
|
||||
remove|ember-template-lint|no-action|1|71|1|71|2e6351f546807d88cc8eb9dbe8baa149468b5cb9|1746489600000|||app/components/gh-view-title.hbs
|
||||
remove|ember-template-lint|no-invalid-role|1|0|1|0|3e651d38e0110e1be20e5082075db1b879b59a36|1746489600000|||app/components/gh-view-title.hbs
|
||||
add|ember-template-lint|no-yield-only|1|0|1|0|a5fa6e8c1e0f03fb31b5cd17770e4368d44932e4|1771200000000|||app/components/gh-view-title.hbs
|
||||
@@ -0,0 +1,5 @@
|
||||
module.exports = {
|
||||
'ember-template-lint': {
|
||||
daysToDecay: null
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
module.exports = {
|
||||
extends: "recommended",
|
||||
|
||||
rules: {
|
||||
'no-forbidden-elements': ['meta', 'html', 'script'],
|
||||
'no-implicit-this': {allow: ['noop', 'now', 'site-icon-style']},
|
||||
'no-inline-styles': false,
|
||||
'no-duplicate-landmark-elements': false,
|
||||
'no-pointer-down-event-binding': false,
|
||||
'no-triple-curlies': false
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"ignore_dirs": ["tmp", "dist"]
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
# Ghost-Admin
|
||||
|
||||
This is the home of the Ember.js-based Admin app that ships with [Ghost](https://github.com/tryghost/ghost).
|
||||
|
||||
## Test
|
||||
|
||||
### Running tests in the browser
|
||||
|
||||
Run all tests in the browser by running `pnpm dev` in the Ghost monorepo and visiting http://localhost:4200/tests. The code is hotloaded on change and you can filter which tests to run.
|
||||
|
||||
[Testing public documentation](https://ghost.notion.site/Testing-Ember-560cec6700fc4d37a58b3ba9febb4b4b)
|
||||
|
||||
---
|
||||
|
||||
Tip: You can use `this.timeout(0); await this.pauseTest();` in your tests to temporarily pause the execution of browser tests. Use the browser console to inspect and debug the DOM, then resume tests by running `resumeTest()` directly in the browser console ([docs](https://guides.emberjs.com/v3.28.0/testing/testing-application/#toc_debugging-your-tests))
|
||||
|
||||
|
||||
### Running tests in the CLI
|
||||
|
||||
To build and run tests in the CLI, you can use:
|
||||
|
||||
```bash
|
||||
TZ=UTC pnpm test
|
||||
```
|
||||
_Note the `TZ=UTC` environment variable which is currently required to get tests working if your system timezone doesn't match UTC._
|
||||
|
||||
---
|
||||
|
||||
However, this is very slow when writing tests, as it requires the app to be rebuilt on every change. Instead, create a separate watching build with:
|
||||
|
||||
```bash
|
||||
pnpm build --environment=test -w -o="dist-test"
|
||||
```
|
||||
|
||||
Then run tests with:
|
||||
|
||||
```bash
|
||||
TZ=UTC pnpm test 1 --reporter dot --path="dist-test"
|
||||
```
|
||||
|
||||
The `--reporter dot` shows a dot (`.`) for every successful test, and `F` for every failed test. It renders the output of the failed tests only.
|
||||
|
||||
---
|
||||
|
||||
To run a specific test file:
|
||||
```bash
|
||||
TZ=UTC pnpm test 1 --reporter dot --path="dist-test" -mp=tests/unit/helpers/gh-count-characters-test.js
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
To have a full list of the available options, run
|
||||
```bash
|
||||
ember exam --help
|
||||
```
|
||||
|
||||
# Copyright & License
|
||||
|
||||
Copyright (c) 2013-2026 Ghost Foundation - Released under the [MIT license](LICENSE). Ghost and the Ghost Logo are trademarks of Ghost Foundation Ltd. Please see our [trademark policy](https://ghost.org/trademark/) for info on acceptable usage.
|
||||
@@ -0,0 +1,30 @@
|
||||
# Ghost Admin App
|
||||
|
||||
Ember.js application used as a client-side admin for the [Ghost](http://ghost.org) blogging platform. This readme is a work in progress guide aimed at explaining the specific nuances of the Ghost Ember app to contributors whose main focus is on this side of things.
|
||||
|
||||
|
||||
## CSS
|
||||
|
||||
We use pure CSS, which is pre-processed for backwards compatibility by [Myth](http://myth.io). We do not follow any strict CSS framework, however our general style is pretty similar to BEM.
|
||||
|
||||
Styles are primarily broken up into 4 main categories:
|
||||
|
||||
* **Patterns** - are base level visual styles for HTML elements (eg. Buttons)
|
||||
* **Components** - are groups of patterns used to create a UI component (eg. Modals)
|
||||
* **Layouts** - are groups of components used to create application screens (eg. Settings)
|
||||
|
||||
All of these separate files are subsequently imported and compiled in `app.css`.
|
||||
|
||||
|
||||
## Front End Standards
|
||||
|
||||
* 4 spaces for HTML & CSS indentation. Never tabs.
|
||||
* Double quotes only, never single quotes.
|
||||
* Use tags and elements appropriate for an HTML5 doctype (including self-closing tags)
|
||||
* Adhere to the [Recess CSS](http://markdotto.com/2011/11/29/css-property-order/) property order.
|
||||
* Always a space after a property's colon (.e.g, display: block; and not display:block;).
|
||||
* End all lines with a semi-colon.
|
||||
* For multiple, comma-separated selectors, place each selector on its own line.
|
||||
* Use js- prefixed classes for JavaScript hooks into the DOM, and never use these in CSS as per [Slightly Obtrusive JavaScript](http://ozmm.org/posts/slightly_obtrusive_javascript.html)
|
||||
* Avoid over-nesting CSS. Never nest more than 3 levels deep.
|
||||
* Use comments to explain "why" not "what" (Good: This requires a z-index in order to appear above mobile navigation. Bad: This is a thing which is always on top!)
|
||||
Executable
+54
@@ -0,0 +1,54 @@
|
||||
import 'ghost-admin/utils/link-component';
|
||||
import 'ghost-admin/utils/route';
|
||||
import Application from '@ember/application';
|
||||
import Resolver from 'ember-resolver';
|
||||
import config from 'ghost-admin/config/environment';
|
||||
import loadInitializers from 'ember-load-initializers';
|
||||
import moment from 'moment-timezone';
|
||||
import {registerWarnHandler} from '@ember/debug';
|
||||
|
||||
moment.updateLocale('en', {
|
||||
relativeTime: {
|
||||
m: '1 minute'
|
||||
}
|
||||
});
|
||||
|
||||
const rootElement = document.getElementById('ember-app');
|
||||
|
||||
const App = Application.extend({
|
||||
Resolver,
|
||||
modulePrefix: config.modulePrefix,
|
||||
podModulePrefix: config.podModulePrefix,
|
||||
|
||||
// eslint-disable-next-line
|
||||
customEvents: {
|
||||
touchstart: null,
|
||||
touchmove: null,
|
||||
touchend: null,
|
||||
touchcancel: null
|
||||
},
|
||||
|
||||
...(rootElement ? {
|
||||
rootElement: '#ember-app'
|
||||
} : {})
|
||||
});
|
||||
|
||||
// TODO: remove once the validations refactor is complete
|
||||
// eslint-disable-next-line
|
||||
registerWarnHandler((message, options, next) => {
|
||||
let skip = [
|
||||
'ds.errors.add',
|
||||
'ds.errors.remove',
|
||||
'ds.errors.clear'
|
||||
];
|
||||
|
||||
if (skip.includes(options.id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
next(message, options);
|
||||
});
|
||||
|
||||
loadInitializers(App, config.modulePrefix);
|
||||
|
||||
export default App;
|
||||
@@ -0,0 +1,52 @@
|
||||
<!DOCTYPE html>
|
||||
<html class="no-js" lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
|
||||
<title>Ghost</title>
|
||||
|
||||
{{content-for "head"}}
|
||||
|
||||
<meta name="viewport" content="user-scalable=no, width=device-width, initial-scale=1, maximum-scale=1, minimal-ui, viewport-fit=cover" />
|
||||
<meta name="pinterest" content="nopin" />
|
||||
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="application-name" content="Ghost" />
|
||||
<meta name="apple-mobile-web-app-title" content="Ghost" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black" />
|
||||
|
||||
<link rel="shortcut icon" href="assets/img/favicon.ico" />
|
||||
<link rel="apple-touch-icon" href="assets/img/apple-touch-icon.png" />
|
||||
|
||||
<!-- variables that we don't want postcss-custom-properties to remove -->
|
||||
<style>
|
||||
:root {
|
||||
--editor-sidebar-width: 0px;
|
||||
}
|
||||
</style>
|
||||
|
||||
<link integrity="" rel="stylesheet" href="{{rootURL}}assets/vendor.css">
|
||||
<link integrity="" rel="stylesheet" href="{{rootURL}}assets/ghost.css" title="light">
|
||||
|
||||
{{content-for "head-footer"}}
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="ember-load-indicator">
|
||||
<div class="gh-loading-content">
|
||||
<video width="100" height="100" loop autoplay muted playsinline preload="metadata" style="width: 100px; height: 100px;">
|
||||
<source src="assets/videos/logo-loader.mp4" type="video/mp4" />
|
||||
<div class="gh-loading-spinner"></div>
|
||||
</video>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{content-for "body"}}
|
||||
|
||||
{{content-for "body-footer"}}
|
||||
|
||||
<script src="{{rootURL}}assets/vendor.js"></script>
|
||||
<script src="{{rootURL}}assets/ghost.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,70 @@
|
||||
import EmberRouter from '@ember/routing/router';
|
||||
import config from 'ghost-admin/config/environment';
|
||||
import ghostPaths from 'ghost-admin/utils/ghost-paths';
|
||||
|
||||
const Router = EmberRouter.extend({
|
||||
location: config.locationType, // use HTML5 History API instead of hash-tag based URLs
|
||||
rootURL: ghostPaths().adminRoot // admin interface lives under sub-directory /ghost
|
||||
});
|
||||
|
||||
// eslint-disable-next-line array-callback-return
|
||||
Router.map(function () {
|
||||
this.route('home', {path: '/'});
|
||||
|
||||
this.route('setup');
|
||||
this.route('setup.done', {path: '/setup/done'});
|
||||
|
||||
this.route('signin');
|
||||
this.route('signin-verify', {path: '/signin/verify'});
|
||||
this.route('signout');
|
||||
this.route('signup', {path: '/signup/:token'});
|
||||
this.route('reset', {path: '/reset/:token'});
|
||||
|
||||
this.route('site');
|
||||
this.route('dashboard');
|
||||
this.route('launch');
|
||||
|
||||
this.route('pro', function () {
|
||||
this.route('pro-sub', {path: '/*sub'});
|
||||
});
|
||||
|
||||
this.route('posts');
|
||||
this.route('posts.debug', {path: '/posts/analytics/:post_id/debug'});
|
||||
this.route('restore-posts', {path: '/restore'});
|
||||
|
||||
this.route('pages');
|
||||
|
||||
this.route('lexical-editor', {path: 'editor'}, function () {
|
||||
this.route('new', {path: ':type'});
|
||||
this.route('edit', {path: ':type/:post_id'});
|
||||
});
|
||||
|
||||
this.route('tag.new', {path: '/tags/new'});
|
||||
this.route('tag', {path: '/tags/:tag_slug'});
|
||||
|
||||
this.route('explore', function () {
|
||||
// actual Ember route, not rendered in iframe
|
||||
this.route('connect');
|
||||
// iframe sub pages, used for categories
|
||||
this.route('explore-sub', {path: '/*sub'}, function () {
|
||||
// needed to allow search to work, as it uses URL
|
||||
// params for search queries. They don't need to
|
||||
// be visible, but may not be cut off.
|
||||
this.route('explore-query', {path: '/*query'});
|
||||
});
|
||||
});
|
||||
|
||||
this.route('migrate', function () {
|
||||
this.route('migrate', {path: '/*platform'});
|
||||
});
|
||||
|
||||
this.route('member.new', {path: '/members/new'});
|
||||
this.route('member', {path: '/members/:member_id'});
|
||||
this.route('members-activity');
|
||||
|
||||
this.route('react-fallback', {path: '/*path'});
|
||||
|
||||
this.route('designsandbox');
|
||||
});
|
||||
|
||||
export default Router;
|
||||
@@ -0,0 +1,21 @@
|
||||
export default function () {
|
||||
this.transition(
|
||||
this.hasClass('fullscreen-modal-container'),
|
||||
this.toValue(true),
|
||||
this.use('fade', {duration: 150}),
|
||||
this.reverse('fade', {duration: 150})
|
||||
);
|
||||
|
||||
this.transition(
|
||||
this.hasClass('fade-transition'),
|
||||
this.use('crossFade', {duration: 100})
|
||||
);
|
||||
|
||||
// TODO: Maybe animate with explode. gh-unsplash-window should ideally slide in from bottom to top of screen
|
||||
// this.transition(
|
||||
// this.hasClass('gh-unsplash-window'),
|
||||
// this.toValue(true),
|
||||
// this.use('toUp', {duration: 500}),
|
||||
// this.reverse('toDown', {duration: 500})
|
||||
// );
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
self.deprecationWorkflow = self.deprecationWorkflow || {};
|
||||
self.deprecationWorkflow.config = {
|
||||
workflow: [
|
||||
// remove once ember-drag-drop removes usage of Component#sendAction
|
||||
// https://github.com/mharris717/ember-drag-drop/issues/155
|
||||
{handler: 'silence', matchId: 'ember-component.send-action'},
|
||||
|
||||
// remove once liquid-fire and liquid-wormhole remove uses of `this.$()`
|
||||
{handler: 'silence', matchId: 'ember-views.curly-components.jquery-element'}
|
||||
]
|
||||
};
|
||||
@@ -0,0 +1,82 @@
|
||||
/* eslint-env node */
|
||||
'use strict';
|
||||
|
||||
module.exports = function (environment) {
|
||||
let ENV = {
|
||||
modulePrefix: 'ghost-admin',
|
||||
environment,
|
||||
editorUrl: process.env.EDITOR_URL || '',
|
||||
rootURL: '',
|
||||
locationType: 'trailing-hash',
|
||||
EmberENV: {
|
||||
FEATURES: {
|
||||
// Here you can enable experimental features on an ember canary build
|
||||
// e.g. EMBER_NATIVE_DECORATOR_SUPPORT: true
|
||||
},
|
||||
// @TODO verify that String/Function need to be enabled
|
||||
EXTEND_PROTOTYPES: {
|
||||
Date: false,
|
||||
Array: true,
|
||||
String: true,
|
||||
Function: false
|
||||
}
|
||||
},
|
||||
|
||||
APP: {
|
||||
// Here you can pass flags/options to your application instance
|
||||
// when it is created
|
||||
|
||||
// override the default version string which contains git info from
|
||||
// https://github.com/cibernox/git-repo-version. Only include the
|
||||
// `major.minor` version numbers
|
||||
version: require('../package.json').version.match(/^(\d+\.)?(\d+)/)[0]
|
||||
},
|
||||
|
||||
'ember-simple-auth': { },
|
||||
|
||||
'@sentry/ember': {
|
||||
disablePerformance: true,
|
||||
sentry: {}
|
||||
}
|
||||
};
|
||||
|
||||
if (environment === 'development') {
|
||||
// ENV.APP.LOG_RESOLVER = true;
|
||||
ENV.APP.LOG_ACTIVE_GENERATION = true;
|
||||
ENV.APP.LOG_TRANSITIONS = true;
|
||||
ENV.APP.LOG_TRANSITIONS_INTERNAL = true;
|
||||
ENV.APP.LOG_VIEW_LOOKUPS = true;
|
||||
|
||||
// Enable mirage here in order to mock API endpoints during development
|
||||
ENV['ember-cli-mirage'] = {
|
||||
enabled: false
|
||||
};
|
||||
}
|
||||
|
||||
if (environment === 'test') {
|
||||
// Testem prefers this...
|
||||
ENV.rootURL = '/';
|
||||
ENV.locationType = 'none';
|
||||
|
||||
// keep test console output quieter
|
||||
ENV.APP.LOG_ACTIVE_GENERATION = false;
|
||||
ENV.APP.LOG_VIEW_LOOKUPS = false;
|
||||
|
||||
ENV.APP.rootElement = '#ember-testing';
|
||||
ENV.APP.autoboot = false;
|
||||
|
||||
// Without manually setting this, pretender won't track requests
|
||||
ENV['ember-cli-mirage'] = {
|
||||
trackRequests: true
|
||||
};
|
||||
|
||||
// We copy the dynamically loaded editor file into the ghost assets
|
||||
// directory in the dev/test env so that tests can load it. We need to
|
||||
// set the config appropriately here so that the fetchKoenigLexical
|
||||
// utility creates the right URL
|
||||
ENV.editorFilename = 'koenig-lexical.umd.js';
|
||||
ENV.editorHash = 'test';
|
||||
}
|
||||
|
||||
return ENV;
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"application-template-wrapper": false,
|
||||
"jquery-integration": true,
|
||||
"template-only-glimmer-components": true
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
/* eslint-env node */
|
||||
|
||||
const browsers = [
|
||||
'last 2 Chrome versions',
|
||||
'last 2 Firefox versions',
|
||||
'last 3 Safari versions',
|
||||
'last 2 Edge versions'
|
||||
];
|
||||
|
||||
module.exports = {
|
||||
browsers
|
||||
};
|
||||
@@ -0,0 +1,275 @@
|
||||
/* eslint-env node */
|
||||
'use strict';
|
||||
|
||||
// Check Node.js version compatibility before building
|
||||
require('./lib/check-node-version')();
|
||||
|
||||
const EmberApp = require('ember-cli/lib/broccoli/ember-app');
|
||||
const concat = require('broccoli-concat');
|
||||
const mergeTrees = require('broccoli-merge-trees');
|
||||
const Terser = require('broccoli-terser-sourcemap');
|
||||
const Funnel = require('broccoli-funnel');
|
||||
const webpack = require('webpack');
|
||||
const environment = EmberApp.env();
|
||||
const isDevelopment = environment === 'development';
|
||||
const isProduction = environment === 'production';
|
||||
const isTesting = environment === 'test';
|
||||
|
||||
const postcssImport = require('postcss-import');
|
||||
const postcssCustomProperties = require('postcss-custom-properties');
|
||||
const postcssColorModFunction = require('postcss-color-mod-function');
|
||||
const postcssCustomMedia = require('postcss-custom-media');
|
||||
const autoprefixer = require('autoprefixer');
|
||||
const cssnano = require('cssnano');
|
||||
|
||||
const codemirrorAssets = function () {
|
||||
let codemirrorFiles = [
|
||||
'lib/codemirror.js',
|
||||
'mode/htmlmixed/htmlmixed.js',
|
||||
'mode/xml/xml.js',
|
||||
'mode/css/css.js',
|
||||
'mode/javascript/javascript.js'
|
||||
];
|
||||
|
||||
if (environment === 'test') {
|
||||
return {import: codemirrorFiles};
|
||||
}
|
||||
|
||||
let config = {};
|
||||
|
||||
config.public = {
|
||||
include: codemirrorFiles,
|
||||
destDir: '/',
|
||||
processTree(tree) {
|
||||
let jsTree = concat(tree, {
|
||||
outputFile: 'assets/codemirror/codemirror.js',
|
||||
headerFiles: ['lib/codemirror.js'],
|
||||
inputFiles: ['mode/**/*'],
|
||||
sourceMapConfig: {enabled: false}
|
||||
});
|
||||
|
||||
if (isProduction) {
|
||||
jsTree = new Terser(jsTree);
|
||||
}
|
||||
|
||||
let mergedTree = mergeTrees([tree, jsTree]);
|
||||
return new Funnel(mergedTree, {include: ['assets/**/*', 'theme/**/*']});
|
||||
}
|
||||
};
|
||||
|
||||
// put the files in vendor ready for importing into the test-support file
|
||||
if (environment === 'development') {
|
||||
config.vendor = codemirrorFiles;
|
||||
}
|
||||
|
||||
return config;
|
||||
};
|
||||
|
||||
let denylist = [];
|
||||
if (process.env.CI) {
|
||||
denylist.push('ember-cli-eslint');
|
||||
}
|
||||
|
||||
let publicAssetURL;
|
||||
|
||||
if (isTesting) {
|
||||
publicAssetURL = undefined;
|
||||
} else if (process.env.GHOST_CDN_URL) {
|
||||
publicAssetURL = process.env.GHOST_CDN_URL + 'assets/';
|
||||
} else {
|
||||
publicAssetURL = 'assets/';
|
||||
}
|
||||
|
||||
module.exports = function (defaults) {
|
||||
let app = new EmberApp(defaults, {
|
||||
addons: {denylist},
|
||||
babel: {
|
||||
plugins: [
|
||||
require.resolve('babel-plugin-transform-react-jsx')
|
||||
]
|
||||
},
|
||||
'ember-cli-babel': {
|
||||
optional: ['es6.spec.symbols'],
|
||||
includePolyfill: false
|
||||
},
|
||||
'ember-composable-helpers': {
|
||||
only: ['join', 'optional', 'pick', 'toggle', 'toggle-action', 'compute']
|
||||
},
|
||||
'ember-promise-modals': {
|
||||
excludeCSS: true
|
||||
},
|
||||
outputPaths: {
|
||||
app: {
|
||||
js: 'assets/ghost.js',
|
||||
css: {
|
||||
app: 'assets/ghost.css',
|
||||
// TODO: find a way to use the .min file with the lazyLoader
|
||||
'app-dark': 'assets/ghost-dark.css'
|
||||
}
|
||||
}
|
||||
},
|
||||
fingerprint: {
|
||||
enabled: isProduction,
|
||||
extensions: [
|
||||
'js',
|
||||
'css',
|
||||
'png',
|
||||
'jpg',
|
||||
'jpeg',
|
||||
'gif',
|
||||
'map',
|
||||
'svg',
|
||||
'ttf',
|
||||
'woff2',
|
||||
'mp4',
|
||||
'ico'
|
||||
],
|
||||
exclude: ['**/chunk*.map']
|
||||
},
|
||||
minifyJS: {
|
||||
options: {
|
||||
output: {
|
||||
semicolons: true
|
||||
}
|
||||
}
|
||||
},
|
||||
minifyCSS: {
|
||||
// postcss already handles minification and this was stripping required CSS
|
||||
enabled: false
|
||||
},
|
||||
nodeAssets: {
|
||||
codemirror: codemirrorAssets()
|
||||
},
|
||||
postcssOptions: {
|
||||
compile: {
|
||||
enabled: true,
|
||||
plugins: [
|
||||
{
|
||||
module: postcssImport,
|
||||
options: {
|
||||
path: ['app/styles']
|
||||
}
|
||||
},
|
||||
{
|
||||
module: postcssCustomProperties,
|
||||
options: {
|
||||
preserve: false
|
||||
}
|
||||
},
|
||||
{
|
||||
module: postcssColorModFunction
|
||||
},
|
||||
{
|
||||
module: postcssCustomMedia
|
||||
},
|
||||
{
|
||||
module: autoprefixer
|
||||
},
|
||||
{
|
||||
module: cssnano,
|
||||
options: {
|
||||
zindex: false,
|
||||
// cssnano sometimes minifies animations incorrectly causing them to break
|
||||
// See: https://github.com/ben-eb/gulp-cssnano/issues/33#issuecomment-210518957
|
||||
reduceIdents: {
|
||||
keyframes: false
|
||||
},
|
||||
discardUnused: {
|
||||
keyframes: false
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
sourcemaps: {enabled: true},
|
||||
svgJar: {
|
||||
strategy: 'inline',
|
||||
stripPath: false,
|
||||
sourceDirs: [
|
||||
'public/assets/icons'
|
||||
],
|
||||
optimizer: {
|
||||
plugins: [
|
||||
{prefixIds: true},
|
||||
{cleanupIds: false},
|
||||
{removeDimensions: true},
|
||||
{removeTitle: !isDevelopment},
|
||||
{removeXMLNS: true},
|
||||
// Transforms on groups are necessary to work around Firefox
|
||||
// not supporting transform-origin on line/path elements.
|
||||
{convertPathData: {
|
||||
applyTransforms: false
|
||||
}},
|
||||
{moveGroupAttrsToElems: false}
|
||||
]
|
||||
}
|
||||
},
|
||||
autoImport: {
|
||||
publicAssetURL,
|
||||
alias: {
|
||||
'sentry-testkit/browser': 'sentry-testkit/dist/browser'
|
||||
},
|
||||
webpack: {
|
||||
devtool: 'source-map',
|
||||
resolve: {
|
||||
fallback: {
|
||||
util: require.resolve('util'),
|
||||
path: require.resolve('path-browserify'),
|
||||
fs: false
|
||||
}
|
||||
},
|
||||
...(isDevelopment && {
|
||||
cache: {
|
||||
type: 'filesystem',
|
||||
buildDependencies: {
|
||||
config: [__filename]
|
||||
}
|
||||
}
|
||||
}),
|
||||
plugins: [
|
||||
new webpack.ProvidePlugin({
|
||||
process: 'process/browser'
|
||||
})
|
||||
],
|
||||
// disable verbose logging about webpack resolution mismatches
|
||||
// - this is a known issue with mismatched versions of dependencies resulting in duplication rather than a single hoisted version
|
||||
// - we don't plan on fixing this in the short term, so we just silence the noise
|
||||
infrastructureLogging: {
|
||||
level: 'error'
|
||||
}
|
||||
}
|
||||
},
|
||||
'ember-test-selectors': {
|
||||
strip: false
|
||||
}
|
||||
});
|
||||
|
||||
// Stop: Normalize
|
||||
app.import('node_modules/normalize.css/normalize.css');
|
||||
|
||||
// 'dem Styles
|
||||
// import codemirror styles rather than lazy-loading so that
|
||||
// our overrides work correctly
|
||||
app.import('node_modules/codemirror/lib/codemirror.css');
|
||||
app.import('node_modules/codemirror/theme/xq-light.css');
|
||||
|
||||
// 'dem Scripts
|
||||
app.import('node_modules/google-caja-bower/html-css-sanitizer-bundle.js');
|
||||
app.import('node_modules/keymaster/keymaster.js');
|
||||
app.import('node_modules/reframe.js/dist/noframe.js');
|
||||
|
||||
// pull things we rely on via lazy-loading into the test-support.js file so
|
||||
// that tests don't break when running via http://localhost:4200/tests
|
||||
if (app.env === 'development') {
|
||||
app.import('vendor/codemirror/lib/codemirror.js', {type: 'test'});
|
||||
}
|
||||
|
||||
if (app.env === 'development' || app.env === 'test') {
|
||||
// pull dynamic imports into the assets folder so that they can be lazy-loaded in tests
|
||||
// also done in development env so http://localhost:4200/tests works
|
||||
app.import('node_modules/@tryghost/koenig-lexical/dist/koenig-lexical.umd.js', {outputFile: 'ghost/assets/koenig-lexical/koenig-lexical.umd.js'});
|
||||
}
|
||||
|
||||
return app.toTree();
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"schemaVersion": "1.0.0",
|
||||
"packages": [
|
||||
{
|
||||
"name": "ember-cli",
|
||||
"version": "3.20.0",
|
||||
"blueprints": [
|
||||
{
|
||||
"name": "app",
|
||||
"outputRepo": "https://github.com/ember-cli/ember-new-output",
|
||||
"codemodsSource": "ember-app-codemods-manifest@1",
|
||||
"isBaseBlueprint": true,
|
||||
"options": []
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"compilerOptions":{"target":"es6","experimentalDecorators":true},"exclude":["node_modules","bower_components","tmp","vendor",".git","dist"]}
|
||||
@@ -0,0 +1,53 @@
|
||||
/* eslint-env node */
|
||||
'use strict';
|
||||
|
||||
const chalk = require('chalk');
|
||||
const semver = require('semver');
|
||||
|
||||
/**
|
||||
* Check Node.js version compatibility for Ember admin build
|
||||
*
|
||||
* The esm module (required by dependencies) has compatibility issues with
|
||||
* Node.js versions 22.10.0 to 22.17.x. We previously patched esm to work
|
||||
* around this, but to avoid maintaining patches, we now check the version
|
||||
* and provide clear guidance.
|
||||
*/
|
||||
function checkNodeVersion() {
|
||||
const nodeVersion = process.version;
|
||||
const parsedVersion = semver.parse(nodeVersion);
|
||||
|
||||
/* eslint-disable no-console */
|
||||
|
||||
if (!parsedVersion) {
|
||||
console.warn(chalk.yellow(`Warning: Could not parse Node.js version: ${nodeVersion}`));
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if version is in the problematic range: >=22.10.0 <22.18.0
|
||||
const isProblematicVersion = semver.satisfies(nodeVersion, '>=22.10.0 <22.18.0');
|
||||
|
||||
if (isProblematicVersion) {
|
||||
console.error('\n');
|
||||
console.error(chalk.red('='.repeat(80)));
|
||||
console.error(chalk.red('ERROR: Incompatible Node.js version detected'));
|
||||
console.error(chalk.red('='.repeat(80)));
|
||||
console.error();
|
||||
console.error(chalk.yellow(`Current Node.js version: ${chalk.bold(nodeVersion)}`));
|
||||
console.error();
|
||||
console.error(chalk.white('The Ember admin build requires the esm module, which has compatibility'));
|
||||
console.error(chalk.white('issues with Node.js versions 22.10.0 through 22.17.x.'));
|
||||
console.error();
|
||||
console.error(chalk.white('Please use one of the following Node.js versions:'));
|
||||
console.error(chalk.green(' • Node.js 22.18.0 or later'));
|
||||
console.error();
|
||||
console.error(chalk.white('To switch Node.js versions, you can use a version manager:'));
|
||||
console.error(chalk.cyan(' • nvm: nvm install 22.18.0 && nvm use 22.18.0'));
|
||||
console.error();
|
||||
console.error(chalk.red('='.repeat(80)));
|
||||
console.error();
|
||||
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = checkNodeVersion;
|
||||
@@ -0,0 +1,6 @@
|
||||
/* eslint-env node */
|
||||
module.exports = {
|
||||
rules: {
|
||||
'brace-style': 'off'
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
/* eslint-disable ghost/ember/no-test-import-export */
|
||||
import {applyEmberDataSerializers, discoverEmberDataModels} from 'ember-cli-mirage';
|
||||
import {createServer} from 'miragejs';
|
||||
import {isTesting, macroCondition} from '@embroider/macros';
|
||||
|
||||
import devRoutes from './routes-dev';
|
||||
import testRoutes from './routes-test';
|
||||
|
||||
export default function (config) {
|
||||
let finalConfig = {
|
||||
...config,
|
||||
models: {...discoverEmberDataModels(), ...config.models},
|
||||
serializers: applyEmberDataSerializers(config.serializers),
|
||||
routes
|
||||
};
|
||||
|
||||
return createServer(finalConfig);
|
||||
}
|
||||
|
||||
function routes() {
|
||||
if (macroCondition(isTesting())) {
|
||||
testRoutes.call(this);
|
||||
} else {
|
||||
devRoutes.call(this);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import ghostPaths from 'ghost-admin/utils/ghost-paths';
|
||||
|
||||
export default function () {
|
||||
// allow any local requests outside of the namespace (configured below) to hit the real server
|
||||
// _must_ be called before the namespace property is set
|
||||
this.passthrough('/ghost/assets/**');
|
||||
|
||||
this.namespace = ghostPaths().apiRoot;
|
||||
this.timing = 1000; // delay for each request, automatically set to 0 during testing
|
||||
this.logging = true;
|
||||
|
||||
// Mock endpoints here to override real API requests during development, eg...
|
||||
// this.put('/posts/:id/', versionMismatchResponse);
|
||||
// mockTags(this);
|
||||
// this.loadFixtures('settings');
|
||||
|
||||
// keep this line, it allows all other API requests to hit the real server
|
||||
this.passthrough();
|
||||
|
||||
// add any external domains to make sure those get passed through too
|
||||
this.passthrough('http://www.gravatar.com/**');
|
||||
this.passthrough('https://cdn.jsdelivr.net/**');
|
||||
this.passthrough('https://api.unsplash.com/**');
|
||||
this.passthrough('https://ghost.org/**');
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import ghostPaths from 'ghost-admin/utils/ghost-paths';
|
||||
|
||||
import mockApiKeys from './config/api-keys';
|
||||
import mockAuthentication from './config/authentication';
|
||||
import mockConfig from './config/config';
|
||||
import mockEmailPreview from './config/email-preview';
|
||||
import mockEmails from './config/emails';
|
||||
import mockIntegrations from './config/integrations';
|
||||
import mockInvites from './config/invites';
|
||||
import mockLabels from './config/labels';
|
||||
import mockMembers from './config/members';
|
||||
import mockNewsletters from './config/newsletters';
|
||||
import mockOffers from './config/offers';
|
||||
import mockPages from './config/pages';
|
||||
import mockPosts from './config/posts';
|
||||
import mockRoles from './config/roles';
|
||||
import mockSearchIndex from './config/search-index';
|
||||
import mockSettings from './config/settings';
|
||||
import mockSite from './config/site';
|
||||
import mockSlugs from './config/slugs';
|
||||
import mockSnippets from './config/snippets';
|
||||
import mockStats from './config/stats';
|
||||
import mockTags from './config/tags';
|
||||
import mockThemes from './config/themes';
|
||||
import mockTiers from './config/tiers';
|
||||
import mockUploads from './config/uploads';
|
||||
import mockUsers from './config/users';
|
||||
import mockWebhooks from './config/webhooks';
|
||||
|
||||
/* eslint-disable ghost/ember/no-test-import-export */
|
||||
export default function () {
|
||||
this.namespace = ghostPaths().apiRoot;
|
||||
// this.timing = 400; // delay for each request, automatically set to 0 during testing
|
||||
this.logging = false;
|
||||
|
||||
mockApiKeys(this);
|
||||
mockAuthentication(this);
|
||||
mockConfig(this);
|
||||
mockEmailPreview(this);
|
||||
mockEmails(this);
|
||||
mockIntegrations(this);
|
||||
mockInvites(this);
|
||||
mockMembers(this);
|
||||
mockLabels(this);
|
||||
mockPages(this);
|
||||
mockPosts(this);
|
||||
mockRoles(this);
|
||||
mockSearchIndex(this);
|
||||
mockSettings(this);
|
||||
mockSite(this);
|
||||
mockSlugs(this);
|
||||
mockTags(this);
|
||||
mockThemes(this);
|
||||
mockUploads(this);
|
||||
mockUsers(this);
|
||||
mockWebhooks(this);
|
||||
mockTiers(this);
|
||||
mockOffers(this);
|
||||
mockSnippets(this);
|
||||
mockNewsletters(this);
|
||||
mockStats(this);
|
||||
|
||||
/* Notifications -------------------------------------------------------- */
|
||||
|
||||
this.get('/notifications/');
|
||||
|
||||
/* Integrations - Slack Test Notification ------------------------------- */
|
||||
|
||||
this.post('/slack/test', function () {
|
||||
return {};
|
||||
});
|
||||
|
||||
/* Delete all ----------------------------------------------------------- */
|
||||
|
||||
this.del('/db', function (db) {
|
||||
db.posts.all().remove();
|
||||
db.tags.all().remove();
|
||||
}, 204);
|
||||
|
||||
/* limit=all blocker ---------------------------------------------------- */
|
||||
const originalHandledRequest = this.pretender.handledRequest;
|
||||
this.pretender.handledRequest = function (verb, path, request) {
|
||||
originalHandledRequest.call(this, verb, path, request);
|
||||
|
||||
const url = new URL(request.url, window.location.origin);
|
||||
const limit = url.searchParams.get('limit');
|
||||
|
||||
const ALLOWED_LIMIT_ALL = [
|
||||
'/api/admin/members/upload/'
|
||||
];
|
||||
|
||||
// limit=all is completely blocked, we shouldn't have any requests reach the server with this
|
||||
if (limit === 'all' && !ALLOWED_LIMIT_ALL.some(allowed => path.includes(allowed))) {
|
||||
throw new Error(`Blocked mirage request with limit=all: ${verb} ${path}.`);
|
||||
}
|
||||
|
||||
// limit > 100 is also blocked, apart from some specific endpoints
|
||||
if (limit && parseInt(limit, 10) > 100) {
|
||||
const parsedLimit = parseInt(limit, 10);
|
||||
|
||||
const SEARCH_ENDPOINTS_REGEX = /\/api\/admin\/(posts|pages|tags|users)\//;
|
||||
if (parsedLimit === 10000 && SEARCH_ENDPOINTS_REGEX.test(path)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (path.match(/\/emails\/.*\/batches\//)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (path.includes('/api/admin/posts/export/')) {
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error(`Blocked mirage request with limit > 100: ${verb} ${path}.`);
|
||||
}
|
||||
};
|
||||
|
||||
/* External sites ------------------------------------------------------- */
|
||||
|
||||
this.head('http://www.gravatar.com/avatar/:md5', function () {
|
||||
return '';
|
||||
}, 200);
|
||||
|
||||
this.get('http://www.gravatar.com/avatar/:md5', function () {
|
||||
return '';
|
||||
}, 200);
|
||||
|
||||
this.get('https://ghost.org/changelog.json', function () {
|
||||
return {
|
||||
posts: [
|
||||
{
|
||||
title: 'Custom image alt tags',
|
||||
custom_excerpt: 'Alt tag support for images in the Ghost editor',
|
||||
slug: 'image-alt-text-support',
|
||||
published_at: '2019-08-05T07:46:16.000+00:00',
|
||||
url: 'https://ghost.org/changelog/image-alt-text-support/',
|
||||
featured: 'false'
|
||||
}
|
||||
],
|
||||
changelogUrl: 'https://ghost.org/changelog/'
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import {Response} from 'miragejs';
|
||||
import {isArray} from '@ember/array';
|
||||
|
||||
export function paginatedResponse(modelName) {
|
||||
return function (schema, request) {
|
||||
let page = +request.queryParams.page || 1;
|
||||
let limit = request.queryParams.limit;
|
||||
let collection = schema[modelName].all();
|
||||
|
||||
if (limit !== 'all') {
|
||||
limit = +request.queryParams.limit || 15;
|
||||
}
|
||||
|
||||
return paginateModelCollection(modelName, collection, page, limit);
|
||||
};
|
||||
}
|
||||
|
||||
export function paginateModelCollection(modelName, collection, page, limit) {
|
||||
let pages, next, prev, models;
|
||||
|
||||
page = parseInt(page, 10);
|
||||
limit = parseInt(limit, 10);
|
||||
|
||||
if (limit === 'all') {
|
||||
pages = 1;
|
||||
} else {
|
||||
limit = +limit;
|
||||
|
||||
let start = (page - 1) * limit;
|
||||
let end = start + limit;
|
||||
|
||||
pages = Math.ceil(collection.models.length / limit);
|
||||
models = collection.models.slice(start, end);
|
||||
|
||||
if (start > 0) {
|
||||
prev = page - 1;
|
||||
}
|
||||
|
||||
if (end < collection.models.length) {
|
||||
next = page + 1;
|
||||
}
|
||||
}
|
||||
|
||||
collection.meta = {
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
pages,
|
||||
total: collection.models.length,
|
||||
next: next || null,
|
||||
prev: prev || null
|
||||
}
|
||||
};
|
||||
|
||||
if (models) {
|
||||
collection.models = models;
|
||||
}
|
||||
|
||||
return collection;
|
||||
}
|
||||
|
||||
export function maintenanceResponse() {
|
||||
return new Response(503, {}, {
|
||||
errors: [{
|
||||
type: 'Maintenance'
|
||||
}]
|
||||
});
|
||||
}
|
||||
|
||||
export function versionMismatchResponse() {
|
||||
return new Response(400, {}, {
|
||||
errors: [{
|
||||
type: 'VersionMismatchError'
|
||||
}]
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeBooleanParams(arr) {
|
||||
if (!isArray(arr)) {
|
||||
return arr;
|
||||
}
|
||||
|
||||
return arr.map((i) => {
|
||||
if (i === 'true') {
|
||||
return true;
|
||||
} else if (i === 'false') {
|
||||
return false;
|
||||
} else {
|
||||
return i;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeStringParams(arr) {
|
||||
if (!isArray(arr)) {
|
||||
return arr;
|
||||
}
|
||||
|
||||
return arr.map((i) => {
|
||||
if (!i.replace) {
|
||||
return i;
|
||||
}
|
||||
|
||||
return i.replace(/^['"]|['"]$/g, '');
|
||||
});
|
||||
}
|
||||
|
||||
// TODO: use GQL to parse filter string?
|
||||
export function extractFilterParam(param, filter = '') {
|
||||
let filterRegex = new RegExp(`${param}:(.*?)(?:\\+|$)`);
|
||||
let match;
|
||||
|
||||
let [, result] = filter.match(filterRegex) || [];
|
||||
|
||||
if (!result) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.startsWith('[')) {
|
||||
match = result.replace(/^\[|\]$/g, '').split(',');
|
||||
} else if (result.startsWith('~')) {
|
||||
match = result.replace(/^~/, '').replace(/\\'/g, `'`).replace(/^'|'$/g, '');
|
||||
} else {
|
||||
match = [result];
|
||||
}
|
||||
|
||||
return normalizeBooleanParams(normalizeStringParams(match));
|
||||
}
|
||||
|
||||
export function hasInvalidPermissions(allowedRoles) {
|
||||
const {schema, request} = this;
|
||||
|
||||
// always allow dev requests through - the logged in user will be real so
|
||||
// we can't check against it in the mocked db
|
||||
if (!request.requestHeaders['X-Test-User']) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const invalidPermsResponse = new Response(403, {}, {
|
||||
errors: [{
|
||||
type: 'NoPermissionError',
|
||||
message: 'You do not have permission to perform this action'
|
||||
}]
|
||||
});
|
||||
|
||||
const user = schema.users.find(request.requestHeaders['X-Test-User']);
|
||||
const adminRoles = user.roles.filter(role => allowedRoles.includes(role.name));
|
||||
|
||||
if (adminRoles.length === 0) {
|
||||
return invalidPermsResponse;
|
||||
}
|
||||
}
|
||||
|
||||
export function withPermissionsCheck(allowedRoles, fn) {
|
||||
return function () {
|
||||
const boundPermsCheck = hasInvalidPermissions.bind(this);
|
||||
const boundFn = fn.bind(this);
|
||||
return boundPermsCheck(allowedRoles) || boundFn(...arguments);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
{
|
||||
"name": "ghost-admin",
|
||||
"version": "6.33.0-rc.0",
|
||||
"description": "Ember.js admin client for Ghost",
|
||||
"author": "Ghost Foundation",
|
||||
"homepage": "http://ghost.org",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git://github.com/TryGhost/Admin.git"
|
||||
},
|
||||
"bugs": "https://github.com/TryGhost/Ghost/issues",
|
||||
"contributors": "https://github.com/TryGhost/Admin/graphs/contributors",
|
||||
"license": "MIT",
|
||||
"private": true,
|
||||
"directories": {
|
||||
"test": "tests"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "ember serve",
|
||||
"build": "ember build --environment=production --silent",
|
||||
"build:dev": "pnpm build --environment=development",
|
||||
"test:unit": "true",
|
||||
"test": "ember exam --split 2 --parallel",
|
||||
"lint:js": "eslint . --cache",
|
||||
"lint:hbs": "ember-template-lint .",
|
||||
"lint": "pnpm lint:js && pnpm lint:hbs"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^22.13.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/eslint-parser": "7.28.4",
|
||||
"@babel/plugin-proposal-class-properties": "7.18.6",
|
||||
"@babel/plugin-proposal-decorators": "7.28.0",
|
||||
"@ember/jquery": "2.0.0",
|
||||
"@ember/optional-features": "2.1.0",
|
||||
"@ember/render-modifiers": "2.1.0",
|
||||
"@ember/test-helpers": "2.9.6",
|
||||
"@ember/test-waiters": "3.1.0",
|
||||
"@embroider/macros": "1.16.13",
|
||||
"@faker-js/faker": "7.6.0",
|
||||
"@glimmer/component": "1.1.2",
|
||||
"@html-next/vertical-collection": "3.0.0",
|
||||
"@sentry/ember": "7.120.3",
|
||||
"@sentry/integrations": "7.114.0",
|
||||
"@sentry/replay": "7.116.0",
|
||||
"@tryghost/admin-x-framework": "workspace:*",
|
||||
"ghost": "workspace:*",
|
||||
"@tryghost/color-utils": "0.2.16",
|
||||
"@tryghost/ember-promise-modals": "2.0.1",
|
||||
"@tryghost/helpers": "1.1.103",
|
||||
"@tryghost/kg-clean-basic-html": "4.2.23",
|
||||
"@tryghost/kg-converters": "1.1.21",
|
||||
"@tryghost/koenig-lexical": "1.7.30",
|
||||
"@tryghost/limit-service": "1.5.2",
|
||||
"@tryghost/members-csv": "2.0.5",
|
||||
"@tryghost/nql": "0.12.10",
|
||||
"@tryghost/nql-lang": "0.6.4",
|
||||
"@tryghost/string": "0.3.2",
|
||||
"@tryghost/timezone-data": "0.4.18",
|
||||
"animejs": "3.2.2",
|
||||
"autoprefixer": "9.8.6",
|
||||
"babel-plugin-transform-class-properties": "6.24.1",
|
||||
"babel-plugin-transform-react-jsx": "6.24.1",
|
||||
"broccoli-asset-rev": "3.0.0",
|
||||
"broccoli-concat": "4.2.7",
|
||||
"broccoli-funnel": "3.0.8",
|
||||
"broccoli-merge-trees": "4.2.0",
|
||||
"broccoli-terser-sourcemap": "4.1.1",
|
||||
"chai": "4.5.0",
|
||||
"chai-dom": "1.12.1",
|
||||
"codemirror": "5.48.2",
|
||||
"cssnano": "4.1.10",
|
||||
"element-resize-detector": "1.2.4",
|
||||
"ember-ajax": "5.1.2",
|
||||
"ember-assign-helper": "0.5.0",
|
||||
"ember-auto-import": "2.10.0",
|
||||
"ember-classic-decorator": "3.0.1",
|
||||
"ember-cli": "3.24.0",
|
||||
"ember-cli-app-version": "5.0.0",
|
||||
"ember-cli-babel": "8.2.0",
|
||||
"ember-cli-chart": "3.7.2",
|
||||
"ember-cli-code-coverage": "1.0.3",
|
||||
"ember-cli-dependency-checker": "3.3.2",
|
||||
"ember-cli-deprecation-workflow": "2.2.0",
|
||||
"ember-cli-htmlbars": "6.3.0",
|
||||
"ember-cli-inject-live-reload": "2.1.0",
|
||||
"ember-cli-mirage": "2.4.0",
|
||||
"ember-cli-node-assets": "0.2.2",
|
||||
"ember-cli-postcss": "6.0.1",
|
||||
"ember-cli-shims": "1.2.0",
|
||||
"ember-cli-string-helpers": "6.1.0",
|
||||
"ember-cli-terser": "4.0.1",
|
||||
"ember-cli-test-loader": "3.1.0",
|
||||
"ember-composable-helpers": "5.0.0",
|
||||
"ember-concurrency": "2.3.7",
|
||||
"ember-could-get-used-to-this": "1.0.1",
|
||||
"ember-css-transitions": "4.4.1",
|
||||
"ember-data": "3.24.0",
|
||||
"ember-decorators": "6.1.1",
|
||||
"ember-drag-drop": "0.4.8",
|
||||
"ember-ella-sparse": "0.16.0",
|
||||
"ember-exam": "6.0.1",
|
||||
"ember-export-application-global": "2.0.1",
|
||||
"ember-fetch": "8.1.2",
|
||||
"ember-in-viewport": "4.1.0",
|
||||
"ember-infinity": "2.3.0",
|
||||
"ember-keyboard": "8.2.1",
|
||||
"ember-load": "0.0.17",
|
||||
"ember-load-initializers": "2.1.2",
|
||||
"ember-mocha": "0.16.2",
|
||||
"ember-modifier": "4.2.0",
|
||||
"ember-moment": "10.0.1",
|
||||
"ember-one-way-select": "4.0.1",
|
||||
"ember-power-datepicker": "0.8.1",
|
||||
"ember-power-select": "6.0.1",
|
||||
"ember-resolver": "8.1.0",
|
||||
"ember-simple-auth": "5.0.0",
|
||||
"ember-sinon": "5.0.0",
|
||||
"ember-source": "3.24.0",
|
||||
"ember-svg-jar": "2.7.1",
|
||||
"ember-template-lint": "5.13.0",
|
||||
"ember-test-selectors": "6.0.0",
|
||||
"ember-tooltips": "3.6.0",
|
||||
"ember-truth-helpers": "3.1.1",
|
||||
"eslint": "catalog:",
|
||||
"eslint-plugin-babel": "5.3.1",
|
||||
"flexsearch": "0.7.43",
|
||||
"fs-extra": "11.3.4",
|
||||
"glob": "8.1.0",
|
||||
"google-caja-bower": "https://github.com/acburdine/google-caja-bower#ghost",
|
||||
"keymaster": "https://github.com/madrobby/keymaster.git",
|
||||
"liquid-fire": "0.34.0",
|
||||
"liquid-wormhole": "3.0.1",
|
||||
"loader.js": "4.7.0",
|
||||
"microdiff": "1.5.0",
|
||||
"miragejs": "0.1.48",
|
||||
"moment-timezone": "0.5.45",
|
||||
"normalize.css": "3.0.3",
|
||||
"papaparse": "5.5.3",
|
||||
"postcss-color-mod-function": "3.0.3",
|
||||
"postcss-custom-media": "7.0.8",
|
||||
"postcss-custom-properties": "10.0.0",
|
||||
"postcss-import": "12.0.1",
|
||||
"pretender": "3.4.7",
|
||||
"process": "0.11.10",
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1",
|
||||
"reframe.js": "4.0.2",
|
||||
"semver": "7.7.4",
|
||||
"sentry-testkit": "5.0.10",
|
||||
"sinon-chai": "4.0.1",
|
||||
"testem": "3.19.1",
|
||||
"tracked-built-ins": "3.4.0",
|
||||
"util": "0.12.5",
|
||||
"validator": "13.12.0",
|
||||
"walk-sync": "3.0.0"
|
||||
},
|
||||
"ember-addon": {
|
||||
"paths": [
|
||||
"lib/asset-delivery",
|
||||
"lib/ember-power-calendar-moment",
|
||||
"lib/ember-power-calendar-utils"
|
||||
]
|
||||
},
|
||||
"ember": {
|
||||
"edition": "octane"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.hbs": "ember-template-lint",
|
||||
"*.js": "eslint"
|
||||
},
|
||||
"dependencies": {
|
||||
"i18n-iso-countries": "7.14.0",
|
||||
"lru-cache": "6.0.0",
|
||||
"path-browserify": "1.0.1",
|
||||
"webpack": "5.105.4"
|
||||
},
|
||||
"nx": {
|
||||
"implicitDependencies": [
|
||||
"!ghost"
|
||||
],
|
||||
"targets": {
|
||||
"build:dev": {
|
||||
"dependsOn": [
|
||||
"build:dev",
|
||||
{
|
||||
"projects": [
|
||||
"@tryghost/admin-x-framework",
|
||||
"@tryghost/admin-x-settings",
|
||||
"@tryghost/activitypub",
|
||||
"@tryghost/posts",
|
||||
"@tryghost/stats"
|
||||
],
|
||||
"target": "build"
|
||||
}
|
||||
]
|
||||
},
|
||||
"build": {
|
||||
"outputs": [
|
||||
"{projectRoot}/dist",
|
||||
"{workspaceRoot}/ghost/core/core/built/admin"
|
||||
],
|
||||
"dependsOn": [
|
||||
"build",
|
||||
{
|
||||
"projects": [
|
||||
"@tryghost/admin-x-framework",
|
||||
"@tryghost/admin-x-settings",
|
||||
"@tryghost/activitypub",
|
||||
"@tryghost/posts",
|
||||
"@tryghost/stats"
|
||||
],
|
||||
"target": "build"
|
||||
}
|
||||
]
|
||||
},
|
||||
"test": {
|
||||
"dependsOn": [
|
||||
{
|
||||
"projects": [
|
||||
"@tryghost/admin-x-framework"
|
||||
],
|
||||
"target": "build"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
/* eslint-env node */
|
||||
/* eslint-disable camelcase */
|
||||
|
||||
let launch_in_ci = [process.env.BROWSER || 'Chrome'];
|
||||
|
||||
module.exports = {
|
||||
framework: 'mocha',
|
||||
browser_start_timeout: 120,
|
||||
browser_disconnect_timeout: 60,
|
||||
test_page: 'tests/index.html?hidepassed',
|
||||
disable_watching: true,
|
||||
parallel: process.env.EMBER_EXAM_SPLIT_COUNT || 1,
|
||||
launch_in_ci,
|
||||
launch_in_dev: [
|
||||
'Chrome',
|
||||
'Firefox'
|
||||
],
|
||||
browser_args: {
|
||||
Chrome: {
|
||||
ci: [
|
||||
// --no-sandbox is needed when running Chrome inside a container
|
||||
process.env.CI ? '--no-sandbox' : null,
|
||||
'--headless',
|
||||
'--disable-dev-shm-usage',
|
||||
'--disable-software-rasterizer',
|
||||
'--mute-audio',
|
||||
'--remote-debugging-port=0',
|
||||
'--window-size=1440,900'
|
||||
].filter(Boolean)
|
||||
},
|
||||
Firefox: {
|
||||
ci: ['-headless']
|
||||
}
|
||||
},
|
||||
tap_failed_tests_only: true
|
||||
};
|
||||
@@ -0,0 +1,51 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<base href="{{rootURL}}">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<title>Ghost Tests</title>
|
||||
<meta name="description" content="">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
|
||||
{{content-for "head"}}
|
||||
{{content-for "test-head"}}
|
||||
|
||||
<link rel="stylesheet" href="{{rootURL}}assets/vendor.css">
|
||||
<link rel="stylesheet" href="{{rootURL}}assets/ghost.css">
|
||||
<link rel="stylesheet" href="{{rootURL}}assets/test-support.css">
|
||||
|
||||
{{content-for "head-footer"}}
|
||||
{{content-for "test-head-footer"}}
|
||||
|
||||
<style>
|
||||
/* fix to ensure we use full viewport height when testing */
|
||||
#ember-testing {
|
||||
height: 100%;
|
||||
}
|
||||
#ember-testing .gh-app {
|
||||
position: relative;
|
||||
}
|
||||
/* fix firefox not supporting `zoom: 50%` */
|
||||
_::-moz-range-track, body:last-child #ember-testing {
|
||||
-moz-transform-origin: 0 0;
|
||||
-moz-transform: scale(0.5);
|
||||
width: 200%;
|
||||
height: 200%;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
{{content-for "body"}}
|
||||
{{content-for "test-body"}}
|
||||
|
||||
<script src="/testem.js" integrity=""></script>
|
||||
<script src="{{rootURL}}assets/vendor.js"></script>
|
||||
<script src="{{rootURL}}assets/test-support.js"></script>
|
||||
<script src="{{rootURL}}assets/ghost.js"></script>
|
||||
<script src="{{rootURL}}assets/tests.js"></script>
|
||||
|
||||
{{content-for "body-footer"}}
|
||||
{{content-for "test-body-footer"}}
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,22 @@
|
||||
import Application from 'ghost-admin/app';
|
||||
import config from 'ghost-admin/config/environment';
|
||||
import registerWaiter from 'ember-raf-scheduler/test-support/register-waiter';
|
||||
import start from 'ember-exam/test-support/start';
|
||||
import {setApplication} from '@ember/test-helpers';
|
||||
|
||||
import chai from 'chai';
|
||||
import chaiDom from 'chai-dom';
|
||||
import sinonChai from 'sinon-chai';
|
||||
chai.use(chaiDom);
|
||||
chai.use(sinonChai);
|
||||
|
||||
setApplication(Application.create(config.APP));
|
||||
|
||||
registerWaiter();
|
||||
|
||||
mocha.setup({
|
||||
timeout: 15000,
|
||||
slow: 500
|
||||
});
|
||||
|
||||
start();
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"all": true,
|
||||
"check-coverage": true,
|
||||
"reporter": [
|
||||
"html-spa",
|
||||
"text-summary",
|
||||
"cobertura"
|
||||
],
|
||||
"reportsDir": "./coverage-e2e",
|
||||
"statements": 54,
|
||||
"branches": 75,
|
||||
"functions": 79,
|
||||
"lines": 54,
|
||||
"include": [
|
||||
"core/{*.js,frontend,server,shared}"
|
||||
],
|
||||
"exclude": [
|
||||
"core/frontend/src/**",
|
||||
"core/frontend/public/**",
|
||||
"core/frontend/helpers/**",
|
||||
"core/server/data/migrations/**",
|
||||
"core/server/data/schema/schema.js",
|
||||
"!core/server/data/migrations/utils.js",
|
||||
"core/server/web/api/testmode/**",
|
||||
"core/server/services/koenig/**"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"all": true,
|
||||
"check-coverage": true,
|
||||
"reporter": [
|
||||
"html-spa",
|
||||
"text-summary",
|
||||
"cobertura"
|
||||
],
|
||||
"statements": 65,
|
||||
"branches": 85,
|
||||
"functions": 65,
|
||||
"lines": 65,
|
||||
"include": [
|
||||
"core/{*.js,frontend,server,shared}"
|
||||
],
|
||||
"exclude": [
|
||||
"core/frontend/src/**",
|
||||
"core/frontend/public/**",
|
||||
"core/server/data/migrations/**",
|
||||
"core/server/data/schema/schema.js",
|
||||
"!core/server/data/migrations/utils.js",
|
||||
"core/frontend/web/**",
|
||||
"!core/frontend/web/middleware/**",
|
||||
"core/server/web/**/app.js",
|
||||
"core/server/web/api/testmode/**",
|
||||
"core/server/web/parent/**",
|
||||
"core/server/api/endpoints/**",
|
||||
"!core/server/web/**/middleware/**",
|
||||
"!core/server/api/endpoints/utils",
|
||||
"core/server/services/email-analytics/jobs/**",
|
||||
"core/server/services/members/jobs/**",
|
||||
"core/server/services/email-service/wrapper.js",
|
||||
"core/server/services/**/service.js"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
core/frontend/src/**/*.js
|
||||
!core/frontend/src/member-attribution/*.js
|
||||
core/frontend/public/**/*.js
|
||||
core/built
|
||||
@@ -0,0 +1,160 @@
|
||||
const path = require('path');
|
||||
|
||||
module.exports = {
|
||||
env: {
|
||||
es6: true,
|
||||
node: true
|
||||
},
|
||||
plugins: ['ghost'],
|
||||
extends: [
|
||||
'plugin:ghost/node'
|
||||
],
|
||||
rules: {
|
||||
'no-var': 'error',
|
||||
'one-var': ['error', 'never']
|
||||
},
|
||||
overrides: [
|
||||
{
|
||||
files: [
|
||||
'**/*.ts'
|
||||
],
|
||||
extends: [
|
||||
'plugin:ghost/ts'
|
||||
],
|
||||
parser: '@typescript-eslint/parser'
|
||||
},
|
||||
{
|
||||
files: 'core/server/api/endpoints/*',
|
||||
rules: {
|
||||
'ghost/ghost-custom/max-api-complexity': 'error'
|
||||
}
|
||||
},
|
||||
{
|
||||
files: 'core/server/data/migrations/versions/**',
|
||||
excludedFiles: [
|
||||
'core/server/data/migrations/versions/1.*/*',
|
||||
'core/server/data/migrations/versions/2.*/*',
|
||||
'core/server/data/migrations/versions/3.*/*'
|
||||
],
|
||||
rules: {
|
||||
'ghost/filenames/match-regex': ['error', '^(?:\\d{4}(?:-\\d{2}){4,5}|\\d{2})(?:-[a-zA-Z0-9]+){2,}$', true]
|
||||
}
|
||||
},
|
||||
{
|
||||
files: 'core/server/data/migrations/versions/**',
|
||||
rules: {
|
||||
'no-restricted-syntax': ['error', {
|
||||
selector: 'ForStatement',
|
||||
message: 'For statements can perform badly in migrations'
|
||||
}, {
|
||||
selector: 'ForOfStatement',
|
||||
message: 'For statements can perform badly in migrations'
|
||||
}, {
|
||||
selector: 'ForInStatement',
|
||||
message: 'For statements can perform badly in migrations'
|
||||
}, {
|
||||
selector: 'WhileStatement',
|
||||
message: 'While statements can perform badly in migrations'
|
||||
}, {
|
||||
selector: 'CallExpression[callee.property.name=\'forEach\']',
|
||||
message: 'Loop constructs like forEach can perform badly in migrations'
|
||||
}, {
|
||||
selector: 'CallExpression[callee.object.name=\'_\'][callee.property.name=\'each\']',
|
||||
message: 'Loop constructs like _.each can perform badly in migrations'
|
||||
}, {
|
||||
selector: 'CallExpression[callee.property.name=/join|innerJoin|leftJoin/] CallExpression[callee.property.name=/join|innerJoin|leftJoin/] CallExpression[callee.name=\'knex\']',
|
||||
message: 'Use of multiple join statements in a single knex block'
|
||||
}],
|
||||
'ghost/no-return-in-loop/no-return-in-loop': ['error']
|
||||
}
|
||||
},
|
||||
{
|
||||
files: 'core/shared/**',
|
||||
rules: {
|
||||
'ghost/node/no-restricted-require': ['error', [
|
||||
{
|
||||
name: path.resolve(__dirname, 'core/server/**'),
|
||||
message: 'Invalid require of core/server from core/shared.'
|
||||
},
|
||||
{
|
||||
name: path.resolve(__dirname, 'core/frontend/**'),
|
||||
message: 'Invalid require of core/frontend from core/shared.'
|
||||
}
|
||||
]]
|
||||
}
|
||||
},
|
||||
{
|
||||
files: 'core/server/data/schema/schema.js',
|
||||
rules: {
|
||||
'no-restricted-syntax': ['error',
|
||||
{
|
||||
selector: 'Property[key.name="created_by"]',
|
||||
message: '`created_by` is not allowed - The action log should be used to record user actions.'
|
||||
},
|
||||
{
|
||||
selector: 'Property[key.name="updated_by"]',
|
||||
message: '`updated_by` is not allowed - The action log should be used to record user actions.'
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
// Enforce kebab-case filenames across core/
|
||||
// Excludes folders for special cases like adapters which need specific file namings
|
||||
files: [
|
||||
'core/**/*.{js,ts}'
|
||||
],
|
||||
excludedFiles: [
|
||||
// Adapter filenames must match the name specified in config (e.g. adapters.cache.active: "Redis").
|
||||
// The adapter-manager loads adapters by constructing a path from the config value.
|
||||
// See: core/shared/config/defaults.json, core/server/services/adapter-manager
|
||||
'core/server/adapters/**'
|
||||
],
|
||||
rules: {
|
||||
'ghost/filenames/match-exported-class': 'off',
|
||||
'ghost/filenames/match-regex': ['error', '^[a-z0-9.-]+$', false]
|
||||
}
|
||||
},
|
||||
{
|
||||
// Helper filenames use underscores because they map directly to Handlebars helper names
|
||||
// e.g., ghost_head.js → {{ghost_head}}. Renaming would break all themes.
|
||||
// See: core/frontend/services/helpers/registry.js:26
|
||||
files: ['core/frontend/helpers/**', 'core/frontend/apps/*/lib/helpers/**'],
|
||||
rules: {
|
||||
'ghost/filenames/match-regex': ['error', '^[a-z0-9_.-]+$', false]
|
||||
}
|
||||
},
|
||||
/**
|
||||
* @TODO: enable these soon
|
||||
*/
|
||||
{
|
||||
files: 'core/frontend/**',
|
||||
rules: {
|
||||
'ghost/node/no-restricted-require': ['off', [
|
||||
// If we make the frontend entirely independent, these have to be solved too
|
||||
// {
|
||||
// name: path.resolve(__dirname, 'core/shared/**'),
|
||||
// message: 'Invalid require of core/shared from core/frontend.'
|
||||
// },
|
||||
// These are critical refactoring issues that we need to tackle ASAP
|
||||
{
|
||||
name: [path.resolve(__dirname, 'core/server/**')],
|
||||
message: 'Invalid require of core/server from core/frontend.'
|
||||
}
|
||||
]]
|
||||
}
|
||||
},
|
||||
{
|
||||
files: 'core/server/**',
|
||||
rules: {
|
||||
'ghost/node/no-restricted-require': ['warn', [
|
||||
{
|
||||
// Throw an error for all requires of the frontend, _except_ the url service which will be moved soon
|
||||
name: [path.resolve(__dirname, 'core/frontend/**')],
|
||||
message: 'Invalid require of core/frontend from core/server.'
|
||||
}
|
||||
]]
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
@@ -0,0 +1,72 @@
|
||||
!**
|
||||
.build
|
||||
.dist
|
||||
.tmp
|
||||
Gruntfile.js
|
||||
ghost-*.tgz
|
||||
.eslintignore
|
||||
.eslintrc.json
|
||||
.eslintrc.js
|
||||
.npmignore
|
||||
.c8rc*.json
|
||||
playwright.config.js
|
||||
jsconfig.json
|
||||
changelog.md*
|
||||
coverage/**
|
||||
docs/**
|
||||
_site/**
|
||||
content/adapters/**
|
||||
!content/adapters/README.md
|
||||
content/apps/**
|
||||
!content/apps/README.md
|
||||
content/public/**
|
||||
!content/public/README.md
|
||||
content/data/**
|
||||
!content/data/README.md
|
||||
content/images/**
|
||||
!content/images/README.md
|
||||
content/logs/**
|
||||
!content/logs/README.md
|
||||
content/settings/**
|
||||
!content/settings/README.md
|
||||
content/themes/**
|
||||
!content/themes/casper/**
|
||||
content/themes/casper/pnpm-lock.yaml
|
||||
!content/themes/source/**
|
||||
content/themes/source/pnpm-lock.yaml
|
||||
node_modules/**
|
||||
core/server/lib/members/static/auth/node_modules/**
|
||||
**/*.db
|
||||
**/*.db-journal
|
||||
*.db
|
||||
*.db-journal
|
||||
.af*
|
||||
.git*
|
||||
.groc*
|
||||
.jshintrc
|
||||
.jscsrc
|
||||
*.iml
|
||||
core/built/**/*.map
|
||||
core/built/**/test-*
|
||||
core/built/**/tests-*
|
||||
test/**
|
||||
CONTRIBUTING.md
|
||||
content/themes/casper/SECURITY.md
|
||||
content/themes/source/SECURITY.md
|
||||
SECURITY.md
|
||||
renovate.json
|
||||
*.html
|
||||
!core/frontend/src/admin-auth/*.html
|
||||
!core/built/admin/**/*.html
|
||||
!core/server/views/**
|
||||
!core/server/services/mail/templates/**
|
||||
bower_components/**
|
||||
.editorconfig
|
||||
monobundle.js
|
||||
!content/themes/casper/gulpfile.js
|
||||
!content/themes/source/gulpfile.js
|
||||
package-lock.json
|
||||
content/themes/casper/config.*.json
|
||||
content/themes/source/config.*.json
|
||||
config.*.json
|
||||
!core/shared/config/env/**
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* knex-migrator requires this exact filename in the project root, therefore, linter naming rules are disabled here.
|
||||
* @see https://github.com/TryGhost/knex-migrator
|
||||
*/
|
||||
/* eslint-disable ghost/filenames/match-regex */
|
||||
const config = require('./core/shared/config');
|
||||
const ghostVersion = require('@tryghost/version');
|
||||
|
||||
/**
|
||||
* knex-migrator can be used via CLI or within the application
|
||||
* when using the CLI, we need to ensure that our global overrides are triggered
|
||||
*/
|
||||
require('./core/server/overrides');
|
||||
|
||||
/**
|
||||
* Register tsx so that require() can resolve .ts files used in server code.
|
||||
*
|
||||
* tsx is a devDependency, so this is a no-op in production where Ghost runs
|
||||
* migrations on boot through its own process (which uses --import=tsx) or in
|
||||
* CI environments where a build has already run and the TS is already compiled
|
||||
*/
|
||||
try {
|
||||
require('tsx/cjs');
|
||||
} catch (err) {
|
||||
if (err.code !== 'MODULE_NOT_FOUND') {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
currentVersion: ghostVersion.safe,
|
||||
database: config.get('database'),
|
||||
migrationPath: config.get('paths:migrationPath')
|
||||
};
|
||||
@@ -0,0 +1,128 @@
|
||||
#!/usr/bin/env node
|
||||
/* eslint-disable no-console, ghost/ghost-custom/no-native-error */
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const semver = require('semver');
|
||||
|
||||
const MIGRATION_TEMPLATE = `const logging = require('@tryghost/logging');
|
||||
|
||||
// For DDL - schema changes
|
||||
// const {createNonTransactionalMigration} = require('../../utils');
|
||||
|
||||
// For DML - data changes
|
||||
// const {createTransactionalMigration} = require('../../utils');
|
||||
|
||||
// Or use a specific helper
|
||||
// const {addTable, createAddColumnMigration} = require('../../utils');
|
||||
|
||||
module.exports = /**/;
|
||||
`;
|
||||
|
||||
const SLUG_PATTERN = /^[a-z0-9]+(-[a-z0-9]+)*$/;
|
||||
|
||||
/**
|
||||
* Validates that a slug is kebab-case (lowercase alphanumeric with single hyphens).
|
||||
*/
|
||||
function isValidSlug(slug) {
|
||||
return typeof slug === 'string' && SLUG_PATTERN.test(slug);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the migration version folder name for the given package version.
|
||||
*
|
||||
* semver.inc(v, 'minor') handles both cases:
|
||||
* - Stable 6.18.0 → 6.19.0 (increments minor)
|
||||
* - Prerelease 6.19.0-rc.0 → 6.19.0 (strips prerelease, keeps minor)
|
||||
*
|
||||
* Key invariant: 6.18.0 and 6.19.0-rc.0 both produce folder "6.19".
|
||||
*/
|
||||
function getNextMigrationVersion(version) {
|
||||
const next = semver.inc(version, 'minor');
|
||||
if (!next) {
|
||||
throw new Error(`Invalid version: ${version}`);
|
||||
}
|
||||
return `${semver.major(next)}.${semver.minor(next)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a migration file and optionally bumps package versions to RC.
|
||||
*
|
||||
* @param {object} options
|
||||
* @param {string} options.slug - The migration name in kebab-case
|
||||
* @param {string} options.coreDir - Path to ghost/core directory
|
||||
* @param {Date} [options.date] - Override the timestamp (for testing)
|
||||
* @returns {{migrationPath: string, rcVersion: string|null}}
|
||||
*/
|
||||
function createMigration({slug, coreDir, date}) {
|
||||
if (!isValidSlug(slug)) {
|
||||
throw new Error(`Invalid slug: "${slug}". Use kebab-case (e.g. add-column-to-posts)`);
|
||||
}
|
||||
|
||||
const migrationsDir = path.join(coreDir, 'core', 'server', 'data', 'migrations', 'versions');
|
||||
const corePackagePath = path.join(coreDir, 'package.json');
|
||||
|
||||
const corePackage = JSON.parse(fs.readFileSync(corePackagePath, 'utf8'));
|
||||
const currentVersion = corePackage.version;
|
||||
|
||||
const nextVersion = getNextMigrationVersion(currentVersion);
|
||||
const versionDir = path.join(migrationsDir, nextVersion);
|
||||
|
||||
const timestamp = (date || new Date()).toISOString().slice(0, 19).replace('T', '-').replaceAll(':', '-');
|
||||
const filename = `${timestamp}-${slug}.js`;
|
||||
const migrationPath = path.join(versionDir, filename);
|
||||
|
||||
fs.mkdirSync(versionDir, {recursive: true});
|
||||
try {
|
||||
fs.writeFileSync(migrationPath, MIGRATION_TEMPLATE, {flag: 'wx'});
|
||||
} catch (err) {
|
||||
if (err.code === 'EEXIST') {
|
||||
throw new Error(`Migration already exists: ${migrationPath}`);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
// Auto-bump to RC if this is a stable version
|
||||
let rcVersion = null;
|
||||
if (!semver.prerelease(currentVersion)) {
|
||||
rcVersion = semver.inc(currentVersion, 'preminor', 'rc');
|
||||
|
||||
corePackage.version = rcVersion;
|
||||
fs.writeFileSync(corePackagePath, JSON.stringify(corePackage, null, 2) + '\n');
|
||||
|
||||
const adminPackagePath = path.resolve(coreDir, '..', 'admin', 'package.json');
|
||||
if (fs.existsSync(adminPackagePath)) {
|
||||
const adminPackage = JSON.parse(fs.readFileSync(adminPackagePath, 'utf8'));
|
||||
adminPackage.version = rcVersion;
|
||||
fs.writeFileSync(adminPackagePath, JSON.stringify(adminPackage, null, 2) + '\n');
|
||||
}
|
||||
}
|
||||
|
||||
return {migrationPath, rcVersion};
|
||||
}
|
||||
|
||||
// CLI entry point
|
||||
if (require.main === module) {
|
||||
const slug = process.argv[2];
|
||||
|
||||
if (!slug) {
|
||||
console.error('Usage: pnpm migrate:create <slug>');
|
||||
console.error(' slug: kebab-case migration name (e.g. add-column-to-posts)');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
try {
|
||||
const coreDir = path.resolve(__dirname, '..');
|
||||
const {migrationPath, rcVersion} = createMigration({slug, coreDir});
|
||||
|
||||
console.log(`Created migration: ${migrationPath}`);
|
||||
if (rcVersion) {
|
||||
console.log(`Bumped version to ${rcVersion}`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err.message);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {isValidSlug, getNextMigrationVersion, createMigration};
|
||||
@@ -0,0 +1,132 @@
|
||||
#!/usr/bin/env node
|
||||
// @ts-check
|
||||
|
||||
/* eslint-disable no-console */
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const jwt = require('jsonwebtoken');
|
||||
|
||||
const GHOST_URL = process.env.GHOST_URL || 'http://localhost:2368';
|
||||
const GOLDEN_POST_PATH = path.join(__dirname, '..', 'test', 'utils', 'fixtures', 'email-service', 'golden-post.json');
|
||||
|
||||
function usage() {
|
||||
console.error('Usage: GHOST_GOLDEN_POST_AUTH=id:secret pnpm generate-golden-email <segment> <output-path>');
|
||||
console.error('');
|
||||
console.error(' segment: Member segment filter, e.g. "status:free" or "status:-free"');
|
||||
console.error(' output-path: Path to write the rendered email HTML');
|
||||
console.error('');
|
||||
console.error('GHOST_GOLDEN_POST_AUTH should be an Admin API key in id:secret format.');
|
||||
console.error('Requires a running Ghost dev instance (pnpm dev).');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {{id: string, secret: string}}
|
||||
*/
|
||||
function getAdminApiKey() {
|
||||
const auth = process.env.GHOST_GOLDEN_POST_AUTH;
|
||||
if (!auth) {
|
||||
console.error('Error: GHOST_GOLDEN_POST_AUTH environment variable is required.');
|
||||
console.error('Set it to an Admin API key.');
|
||||
process.exit(1);
|
||||
}
|
||||
const [id, secret] = auth.split(':');
|
||||
if (id && secret) {
|
||||
return {id, secret};
|
||||
}
|
||||
console.error('Error: GHOST_GOLDEN_POST_AUTH environment variable is invalid.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{id: string, secret: string}} apiKey
|
||||
* @returns {string}
|
||||
*/
|
||||
function signToken(apiKey) {
|
||||
return jwt.sign(
|
||||
{},
|
||||
Buffer.from(apiKey.secret, 'hex'),
|
||||
{
|
||||
keyid: apiKey.id,
|
||||
algorithm: 'HS256',
|
||||
expiresIn: '5m',
|
||||
audience: '/admin/'
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} token
|
||||
* @param {string} method
|
||||
* @param {string} endpoint
|
||||
* @param {object} [body]
|
||||
* @returns {Promise<Record<string, unknown>>}
|
||||
*/
|
||||
async function apiRequest(token, method, endpoint, body) {
|
||||
// god_mode bypasses the integration token endpoint allowlist in development
|
||||
const separator = endpoint.includes('?') ? '&' : '?';
|
||||
const url = `${GHOST_URL}/ghost/api/admin/${endpoint}${separator}god_mode=true`;
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
headers: {
|
||||
Authorization: `Ghost ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
...(body ? {body: JSON.stringify(body)} : {})
|
||||
});
|
||||
assert(res.ok, `API ${method} ${endpoint} failed (${res.status})`);
|
||||
if (res.status === 204) {
|
||||
return {};
|
||||
}
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
if (args.length !== 2) {
|
||||
usage();
|
||||
}
|
||||
const [segment, outputPath] = args;
|
||||
|
||||
const apiKey = getAdminApiKey();
|
||||
const token = signToken(apiKey);
|
||||
|
||||
const goldenPost = JSON.parse(fs.readFileSync(GOLDEN_POST_PATH, 'utf8'));
|
||||
|
||||
const createRes = await apiRequest(token, 'POST', 'posts/', {
|
||||
posts: [{
|
||||
title: 'Golden Email Preview (temp)',
|
||||
status: 'draft',
|
||||
lexical: JSON.stringify(goldenPost)
|
||||
}]
|
||||
});
|
||||
const postId = createRes.posts[0].id;
|
||||
|
||||
try {
|
||||
const previewRes = await apiRequest(
|
||||
token,
|
||||
'GET',
|
||||
`email_previews/posts/${postId}/?memberSegment=${encodeURIComponent(segment)}`
|
||||
);
|
||||
const html = previewRes.email_previews[0].html;
|
||||
|
||||
const resolvedPath = path.resolve(outputPath);
|
||||
fs.writeFileSync(resolvedPath, html, 'utf8');
|
||||
console.log(`Golden email written to ${resolvedPath}`);
|
||||
} finally {
|
||||
try {
|
||||
await apiRequest(token, 'DELETE', `posts/${postId}/`);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
console.error('Warning: failed to clean up draft post:', message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
console.error('Error:', message);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Script to minify multiple JavaScript files using esbuild
|
||||
* Supports per-file configuration for bundling and other options
|
||||
*
|
||||
* The tryghost/minifier package is not used because it is intended to be used at runtime,
|
||||
* allowing for replacements to be made on the fly (e.g. for card-assets and theme activation).
|
||||
*
|
||||
* This script is intended to be run at build time, allowing for us to use the minified files
|
||||
* in the production build and be able to utilize bundler benefits.
|
||||
*/
|
||||
|
||||
const esbuild = require('esbuild');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const logging = require('@tryghost/logging');
|
||||
|
||||
// Determine the root directory by checking for common project files
|
||||
function findProjectRoot() {
|
||||
let currentDir = process.cwd();
|
||||
|
||||
// Check if we're already in ghost/core
|
||||
if (currentDir.endsWith('ghost/core') || currentDir.endsWith('ghost\\core')) {
|
||||
return currentDir;
|
||||
}
|
||||
|
||||
// Look for ghost/core directory
|
||||
const ghostCorePath = path.join(currentDir, 'ghost', 'core');
|
||||
if (fs.existsSync(ghostCorePath)) {
|
||||
return ghostCorePath;
|
||||
}
|
||||
|
||||
return currentDir;
|
||||
}
|
||||
|
||||
const projectRoot = findProjectRoot();
|
||||
logging.debug(`Resolving paths from: ${projectRoot}`);
|
||||
|
||||
// Helper to resolve paths relative to project root
|
||||
function resolvePath(filePath) {
|
||||
return path.join(projectRoot, filePath);
|
||||
}
|
||||
|
||||
// Define files to minify with their specific configuration
|
||||
const filesToMinify = [
|
||||
{
|
||||
src: 'core/frontend/src/comment-counts/comment-counts.js',
|
||||
dest: 'core/frontend/public/comment-counts.min.js',
|
||||
options: {
|
||||
bundle: false
|
||||
}
|
||||
},
|
||||
{
|
||||
src: 'core/frontend/src/ghost-stats/ghost-stats.js',
|
||||
dest: 'core/frontend/public/ghost-stats.min.js',
|
||||
options: {
|
||||
bundle: true,
|
||||
format: 'iife',
|
||||
target: ['es2020']
|
||||
}
|
||||
},
|
||||
{
|
||||
src: 'core/frontend/src/member-attribution/member-attribution.js',
|
||||
dest: 'core/frontend/public/member-attribution.min.js',
|
||||
options: {
|
||||
bundle: true,
|
||||
format: 'iife',
|
||||
target: ['es2020']
|
||||
}
|
||||
},
|
||||
{
|
||||
src: 'core/frontend/src/admin-auth/message-handler.js',
|
||||
dest: 'core/frontend/public/admin-auth/admin-auth.min.js',
|
||||
options: {
|
||||
bundle: false
|
||||
}
|
||||
},
|
||||
{
|
||||
src: 'core/frontend/public/private.js',
|
||||
dest: 'core/frontend/public/private.min.js',
|
||||
options: {
|
||||
bundle: false
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
// Process all files
|
||||
(async () => {
|
||||
logging.debug('Starting JS minification...');
|
||||
|
||||
for (const file of filesToMinify) {
|
||||
try {
|
||||
const srcPath = resolvePath(file.src);
|
||||
const destPath = resolvePath(file.dest);
|
||||
|
||||
// Ensure the destination directory exists
|
||||
const destDir = path.dirname(destPath);
|
||||
if (!fs.existsSync(destDir)) {
|
||||
fs.mkdirSync(destDir, {recursive: true});
|
||||
}
|
||||
|
||||
// Create build configuration by merging default options with file-specific options
|
||||
const buildConfig = {
|
||||
entryPoints: [srcPath],
|
||||
outfile: destPath,
|
||||
minify: true,
|
||||
platform: 'browser',
|
||||
// Apply file-specific options, with defaults
|
||||
...file.options
|
||||
};
|
||||
|
||||
await esbuild.build(buildConfig);
|
||||
|
||||
// Show bundling status in output
|
||||
const bundleStatus = buildConfig.bundle ? 'bundled + minified' : 'minified';
|
||||
logging.debug(`✓ ${file.src} → ${file.dest} (${bundleStatus})`);
|
||||
} catch (error) {
|
||||
console.error(`✗ Error processing ${file.src}:`, error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
logging.debug('JS processing complete');
|
||||
})();
|
||||
@@ -0,0 +1,72 @@
|
||||
const sentry = require('./shared/sentry');
|
||||
const express = require('./shared/express');
|
||||
const config = require('./shared/config');
|
||||
const logging = require('@tryghost/logging');
|
||||
const urlService = require('./server/services/url');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
/** @import {Application as ExpressApplication, Request, RequestHandler} from 'express' */
|
||||
|
||||
/**
|
||||
* @param {Request} req
|
||||
* @returns {boolean}
|
||||
*/
|
||||
const isMaintenanceModeEnabled = (req) => {
|
||||
if (req.app.get('maintenance') || config.get('maintenance').enabled || !urlService.hasFinished()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
/** @type {RequestHandler} */
|
||||
const maintenanceMiddleware = function maintenanceMiddleware(req, res, next) {
|
||||
if (!isMaintenanceModeEnabled(req)) {
|
||||
return next();
|
||||
}
|
||||
|
||||
res.set({
|
||||
'Cache-Control': 'no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0'
|
||||
});
|
||||
res.writeHead(503, {'content-type': 'text/html'});
|
||||
fs.createReadStream(path.resolve(__dirname, './server/views/maintenance.html')).pipe(res);
|
||||
};
|
||||
|
||||
/**
|
||||
* Used by Ghost (Pro) to ensure that requests cannot be served by the wrong site.
|
||||
* @type {RequestHandler}
|
||||
*/
|
||||
const siteIdMiddleware = function siteIdMiddleware(req, res, next) {
|
||||
const configSiteId = config.get('hostSettings:siteId');
|
||||
const headerSiteId = req.headers['x-site-id'];
|
||||
|
||||
if (`${configSiteId}` === `${headerSiteId}`) {
|
||||
return next();
|
||||
}
|
||||
|
||||
logging.warn(`Mismatched site id (expected ${configSiteId}, got ${headerSiteId})`);
|
||||
|
||||
res.set({
|
||||
'Cache-Control': 'no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0'
|
||||
});
|
||||
res.writeHead(500);
|
||||
res.end();
|
||||
};
|
||||
|
||||
/** @returns {ExpressApplication} */
|
||||
const rootApp = () => {
|
||||
const app = express('root');
|
||||
app.use(sentry.requestHandler);
|
||||
if (config.get('sentry')?.tracing?.enabled === true) {
|
||||
app.use(sentry.tracingHandler);
|
||||
}
|
||||
if (config.get('hostSettings:siteId')) {
|
||||
app.use(siteIdMiddleware);
|
||||
}
|
||||
app.enable('maintenance');
|
||||
app.use(maintenanceMiddleware);
|
||||
|
||||
return app;
|
||||
};
|
||||
|
||||
module.exports = rootApp;
|
||||
@@ -0,0 +1,614 @@
|
||||
// The Ghost Boot Sequence
|
||||
// -----------------------
|
||||
// - This is intentionally one big file at the moment, so that we don't have to follow boot logic all over the place
|
||||
// - This file is FULL of debug statements so we can see timings for the various steps because the boot needs to be as fast as possible
|
||||
// - As we manage to break the codebase down into distinct components for e.g. the frontend, their boot logic can be offloaded to them
|
||||
// - app.js is separate as the first example of each component having it's own app.js file colocated with it, instead of inside of server/web
|
||||
//
|
||||
// IMPORTANT:
|
||||
// ----------
|
||||
// The only global requires here should be overrides + debug so we can monitor timings with DEBUG = ghost: boot * node ghost
|
||||
require('./server/overrides');
|
||||
const debug = require('@tryghost/debug')('boot');
|
||||
// END OF GLOBAL REQUIRES
|
||||
|
||||
/**
|
||||
* Helper class to create consistent log messages
|
||||
*/
|
||||
class BootLogger {
|
||||
constructor(logging, metrics, startTime) {
|
||||
this.logging = logging;
|
||||
this.metrics = metrics;
|
||||
this.startTime = startTime;
|
||||
}
|
||||
log(message) {
|
||||
let {logging, startTime} = this;
|
||||
logging.info(`Ghost ${message} in ${(Date.now() - startTime) / 1000}s`);
|
||||
}
|
||||
/**
|
||||
* @param {string} name
|
||||
* @param {number} [initialTime]
|
||||
*/
|
||||
metric(name, initialTime) {
|
||||
let {metrics, startTime} = this;
|
||||
|
||||
if (!initialTime) {
|
||||
initialTime = startTime;
|
||||
}
|
||||
|
||||
metrics.metric(name, Date.now() - initialTime);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to handle sending server ready notifications
|
||||
* @param {string} [error]
|
||||
*/
|
||||
function notifyServerReady(error) {
|
||||
const notify = require('./server/notify');
|
||||
|
||||
if (error) {
|
||||
debug('Notifying server ready (error)');
|
||||
notify.notifyServerReady(error);
|
||||
} else {
|
||||
debug('Notifying server ready (success)');
|
||||
notify.notifyServerReady();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the Database into a ready state
|
||||
* - DatabaseStateManager handles doing all this for us
|
||||
*
|
||||
* @param {object} options
|
||||
* @param {object} options.config
|
||||
*/
|
||||
async function initDatabase({config}) {
|
||||
const DatabaseStateManager = require('./server/data/db/database-state-manager');
|
||||
const dbStateManager = new DatabaseStateManager({knexMigratorFilePath: config.get('paths:appRoot')});
|
||||
await dbStateManager.makeReady();
|
||||
|
||||
const databaseInfo = require('./server/data/db/info');
|
||||
await databaseInfo.init();
|
||||
}
|
||||
|
||||
/**
|
||||
* Core is intended to be all the bits of Ghost that are fundamental and we can't do anything without them!
|
||||
* (There's more to do to make this true)
|
||||
* @param {object} options
|
||||
* @param {object} options.ghostServer
|
||||
* @param {object} options.config
|
||||
* @param {boolean} options.frontend
|
||||
*/
|
||||
async function initCore({ghostServer, config, frontend}) {
|
||||
debug('Begin: initCore');
|
||||
|
||||
// URL Utils is a bit slow, put it here so the timing is visible separate from models
|
||||
debug('Begin: Load urlUtils');
|
||||
require('./shared/url-utils');
|
||||
debug('End: Load urlUtils');
|
||||
|
||||
// Models are the heart of Ghost - this is a syncronous operation
|
||||
debug('Begin: models');
|
||||
const models = require('./server/models');
|
||||
models.init();
|
||||
debug('End: models');
|
||||
|
||||
// Limit service is booted before settings, so that limits are available for calculated settings
|
||||
debug('Begin: limits');
|
||||
const limits = require('./server/services/limits');
|
||||
await limits.init();
|
||||
debug('End: limits');
|
||||
|
||||
// Settings are a core concept we use settings to store key-value pairs used in critical pathways as well as public data like the site title
|
||||
debug('Begin: settings');
|
||||
const settings = require('./server/services/settings/settings-service');
|
||||
await settings.init();
|
||||
await settings.syncEmailSettings(config.get('hostSettings:emailVerification:verified'));
|
||||
debug('End: settings');
|
||||
|
||||
debug('Begin: i18n');
|
||||
const i18n = require('./server/services/i18n');
|
||||
await i18n.init();
|
||||
debug('End: i18n');
|
||||
|
||||
// The URLService is a core part of Ghost, which depends on models.
|
||||
debug('Begin: Url Service');
|
||||
const urlService = require('./server/services/url');
|
||||
// Note: there is no await here, we do not wait for the url service to finish
|
||||
// We can return, but the site will remain in maintenance mode until this finishes
|
||||
// This is managed on request: https://github.com/TryGhost/Ghost/blob/main/core/app.js#L10
|
||||
urlService.init({
|
||||
urlCache: !frontend // hacky parameter to make the cache initialization kick in as we can't initialize labs before the boot
|
||||
});
|
||||
debug('End: Url Service');
|
||||
|
||||
if (ghostServer) {
|
||||
// Job Service allows parts of Ghost to run in the background
|
||||
debug('Begin: Job Service');
|
||||
const jobService = require('./server/services/jobs');
|
||||
|
||||
if (config.get('server:testmode')) {
|
||||
jobService.initTestMode();
|
||||
}
|
||||
|
||||
ghostServer.registerCleanupTask(async () => {
|
||||
await jobService.shutdown();
|
||||
});
|
||||
debug('End: Job Service');
|
||||
|
||||
// Mentions Job Service allows mentions to be processed in the background
|
||||
debug('Begin: Mentions Job Service');
|
||||
const mentionsJobService = require('./server/services/mentions-jobs');
|
||||
|
||||
if (config.get('server:testmode')) {
|
||||
mentionsJobService.initTestMode();
|
||||
}
|
||||
|
||||
ghostServer.registerCleanupTask(async () => {
|
||||
await mentionsJobService.shutdown();
|
||||
});
|
||||
debug('End: Mentions Job Service');
|
||||
|
||||
ghostServer.registerCleanupTask(async () => {
|
||||
await urlService.shutdown();
|
||||
});
|
||||
}
|
||||
|
||||
debug('End: initCore');
|
||||
}
|
||||
|
||||
/**
|
||||
* These are services required by Ghost's frontend.
|
||||
* @param {object} options
|
||||
* @param {object} options.bootLogger
|
||||
|
||||
*/
|
||||
async function initServicesForFrontend({bootLogger}) {
|
||||
debug('Begin: initServicesForFrontend');
|
||||
|
||||
debug('Begin: Routing Settings');
|
||||
const routeSettings = require('./server/services/route-settings');
|
||||
await routeSettings.init();
|
||||
debug('End: Routing Settings');
|
||||
|
||||
debug('Begin: Redirects');
|
||||
const customRedirects = require('./server/services/custom-redirects');
|
||||
await customRedirects.init();
|
||||
debug('End: Redirects');
|
||||
|
||||
debug('Begin: Link Redirects');
|
||||
const linkRedirects = require('./server/services/link-redirection');
|
||||
await linkRedirects.init();
|
||||
debug('End: Link Redirects');
|
||||
|
||||
debug('Begin: Themes');
|
||||
// customThemeSettingsService.api must be initialized before any theme activation occurs
|
||||
const customThemeSettingsService = require('./server/services/custom-theme-settings');
|
||||
customThemeSettingsService.init();
|
||||
|
||||
const themeService = require('./server/services/themes');
|
||||
const themeServiceStart = Date.now();
|
||||
await themeService.init();
|
||||
bootLogger.metric('theme-service-init', themeServiceStart);
|
||||
debug('End: Themes');
|
||||
|
||||
debug('Begin: Offers');
|
||||
const offers = require('./server/services/offers');
|
||||
await offers.init();
|
||||
debug('End: Offers');
|
||||
|
||||
const frontendDataService = require('./server/services/frontend-data-service');
|
||||
let dataService = await frontendDataService.init();
|
||||
|
||||
debug('End: initServicesForFrontend');
|
||||
return {dataService};
|
||||
}
|
||||
|
||||
/**
|
||||
* Frontend is intended to be just Ghost's frontend
|
||||
*/
|
||||
async function initFrontend(dataService) {
|
||||
debug('Begin: initFrontend');
|
||||
|
||||
const proxyService = require('./frontend/services/proxy');
|
||||
proxyService.init({dataService});
|
||||
|
||||
const helperService = require('./frontend/services/helpers');
|
||||
await helperService.init();
|
||||
|
||||
debug('End: initFrontend');
|
||||
}
|
||||
|
||||
/**
|
||||
* At the moment we load our express apps all in one go, they require themselves and are co-located
|
||||
* What we want is to be able to optionally load various components and mount them
|
||||
* So eventually this function should go away
|
||||
* @param {Object} options
|
||||
* @param {boolean} options.backend
|
||||
* @param {boolean} options.frontend
|
||||
* @param {Object} options.config
|
||||
*/
|
||||
async function initExpressApps({frontend, backend, config}) {
|
||||
debug('Begin: initExpressApps');
|
||||
|
||||
const parentApp = require('./server/web/parent/app')();
|
||||
const vhost = require('@tryghost/mw-vhost');
|
||||
|
||||
// Mount the express apps on the parentApp
|
||||
if (backend) {
|
||||
// ADMIN + API
|
||||
const backendApp = require('./server/web/parent/backend')();
|
||||
parentApp.use(vhost(config.getBackendMountPath(), backendApp));
|
||||
}
|
||||
|
||||
if (frontend) {
|
||||
// SITE + MEMBERS
|
||||
const urlService = require('./server/services/url');
|
||||
const frontendApp = require('./server/web/parent/frontend')({urlService});
|
||||
parentApp.use(vhost(config.getFrontendMountPath(), frontendApp));
|
||||
}
|
||||
|
||||
debug('End: initExpressApps');
|
||||
return parentApp;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize prometheus client
|
||||
*/
|
||||
function initPrometheusClient({config}) {
|
||||
if (config.get('prometheus:enabled')) {
|
||||
debug('Begin: initPrometheusClient');
|
||||
const prometheusClient = require('./shared/prometheus-client');
|
||||
debug('End: initPrometheusClient');
|
||||
return prometheusClient;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dynamic routing is generated from the routes.yaml file
|
||||
* When Ghost's DB and core are loaded, we can access this file and call routing.routingManager.start
|
||||
* However this _must_ happen after the express Apps are loaded, hence why this is here and not in initFrontend
|
||||
* Routing is currently tightly coupled between the frontend and backend
|
||||
*/
|
||||
async function initDynamicRouting() {
|
||||
debug('Begin: Dynamic Routing');
|
||||
const routing = require('./frontend/services/routing');
|
||||
const routeSettingsService = require('./server/services/route-settings');
|
||||
const bridge = require('./bridge');
|
||||
bridge.init();
|
||||
|
||||
// We pass the dynamic routes here, so that the frontend services are slightly less tightly-coupled
|
||||
const routeSettings = await routeSettingsService.loadRouteSettings();
|
||||
|
||||
routing.routerManager.start(routeSettings);
|
||||
const getRoutesHash = () => routeSettingsService.api.getCurrentHash();
|
||||
|
||||
const settings = require('./server/services/settings/settings-service');
|
||||
await settings.syncRoutesHash(getRoutesHash);
|
||||
|
||||
debug('End: Dynamic Routing');
|
||||
}
|
||||
|
||||
/**
|
||||
* The app service cannot be loaded unless the frontend is enabled
|
||||
* In future, the logic to determine whether this should be loaded should be in the service loader
|
||||
*/
|
||||
async function initAppService() {
|
||||
debug('Begin: App Service');
|
||||
const appService = require('./frontend/services/apps');
|
||||
await appService.init();
|
||||
}
|
||||
|
||||
/**
|
||||
* Services are components that make up part of Ghost and need initializing on boot
|
||||
* These services should all be part of core, frontend services should be loaded with the frontend
|
||||
* We are working towards this being a service loader, with the ability to make certain services optional
|
||||
*/
|
||||
async function initServices() {
|
||||
debug('Begin: initServices');
|
||||
|
||||
debug('Begin: Services');
|
||||
const identityTokens = require('./server/services/identity-tokens');
|
||||
const stripe = require('./server/services/stripe');
|
||||
const members = require('./server/services/members');
|
||||
const tiers = require('./server/services/tiers');
|
||||
const permissions = require('./server/services/permissions');
|
||||
const indexnow = require('./server/services/indexnow');
|
||||
const slack = require('./server/services/slack');
|
||||
const webhooks = require('./server/services/webhooks');
|
||||
const postScheduling = require('./server/services/post-scheduling');
|
||||
const comments = require('./server/services/comments');
|
||||
const staffService = require('./server/services/staff');
|
||||
const memberAttribution = require('./server/services/member-attribution');
|
||||
const membersEvents = require('./server/services/members-events');
|
||||
const linkTracking = require('./server/services/link-tracking');
|
||||
const audienceFeedback = require('./server/services/audience-feedback');
|
||||
const emailSuppressionList = require('./server/services/email-suppression-list');
|
||||
const emailService = require('./server/services/email-service');
|
||||
const emailAnalytics = require('./server/services/email-analytics');
|
||||
const mentionsService = require('./server/services/mentions');
|
||||
const tagsPublic = require('./server/services/tags-public');
|
||||
const postsPublic = require('./server/services/posts-public');
|
||||
const slackNotifications = require('./server/services/slack-notifications');
|
||||
const mediaInliner = require('./server/services/media-inliner');
|
||||
const donationService = require('./server/services/donations');
|
||||
const giftService = require('./server/services/gifts');
|
||||
const recommendationsService = require('./server/services/recommendations');
|
||||
const emailAddressService = require('./server/services/email-address');
|
||||
const statsService = require('./server/services/stats');
|
||||
const explorePingService = require('./server/services/explore-ping');
|
||||
|
||||
const {
|
||||
createAdapter: createSchedulerAdapter,
|
||||
getSchedulerIntegration
|
||||
} = require('./server/adapters/scheduling/utils');
|
||||
const urlUtils = require('./shared/url-utils');
|
||||
|
||||
// Initialize things that other services depend on first.
|
||||
const schedulerAdapter = createSchedulerAdapter();
|
||||
const [schedulerIntegration] = await Promise.all([
|
||||
getSchedulerIntegration(),
|
||||
stripe.init(),
|
||||
emailAddressService.init()
|
||||
]);
|
||||
|
||||
await Promise.all([
|
||||
identityTokens.init(),
|
||||
memberAttribution.init(),
|
||||
mentionsService.init(),
|
||||
staffService.init(),
|
||||
members.init(),
|
||||
tiers.init(),
|
||||
tagsPublic.init(),
|
||||
postsPublic.init(),
|
||||
membersEvents.init(),
|
||||
permissions.init(),
|
||||
indexnow.listen(),
|
||||
slack.listen(),
|
||||
audienceFeedback.init(),
|
||||
emailService.init(),
|
||||
emailAnalytics.init(),
|
||||
webhooks.listen(),
|
||||
postScheduling.init({
|
||||
apiUrl: urlUtils.urlFor('api', {type: 'admin'}, true),
|
||||
adapter: schedulerAdapter,
|
||||
integration: schedulerIntegration
|
||||
}),
|
||||
comments.init(),
|
||||
linkTracking.init(),
|
||||
emailSuppressionList.init(),
|
||||
slackNotifications.init(),
|
||||
mediaInliner.init(),
|
||||
donationService.init(),
|
||||
recommendationsService.init(),
|
||||
statsService.init(),
|
||||
explorePingService.init(),
|
||||
giftService.init()
|
||||
]);
|
||||
|
||||
debug('End: Services');
|
||||
|
||||
debug('End: initServices');
|
||||
}
|
||||
|
||||
/**
|
||||
* Kick off recurring jobs and background services
|
||||
* These are things that happen on boot, but we don't need to wait for them to finish
|
||||
* Later, this might be a service hook
|
||||
|
||||
* @param {object} options
|
||||
* @param {object} options.config
|
||||
*/
|
||||
async function initBackgroundServices({config}) {
|
||||
debug('Begin: initBackgroundServices');
|
||||
|
||||
// Load all inactive themes
|
||||
const themeService = require('./server/services/themes');
|
||||
themeService.loadInactiveThemes();
|
||||
|
||||
// we don't want to kick off background services that will interfere with tests
|
||||
if (process.env.NODE_ENV.startsWith('test')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const activitypub = require('./server/services/activitypub');
|
||||
await activitypub.init();
|
||||
// Load email analytics recurring jobs
|
||||
if (config.get('backgroundJobs:emailAnalytics')) {
|
||||
const emailAnalyticsJobs = require('./server/services/email-analytics/jobs');
|
||||
await emailAnalyticsJobs.scheduleRecurringJobs();
|
||||
}
|
||||
|
||||
const updateCheck = require('./server/services/update-check');
|
||||
updateCheck.scheduleRecurringJobs();
|
||||
|
||||
const milestonesService = require('./server/services/milestones');
|
||||
milestonesService.initAndRun();
|
||||
|
||||
// TODO(NY-1220): The outbox is deprecated and will soon be removed.
|
||||
const outboxService = require('./server/services/outbox');
|
||||
outboxService.init();
|
||||
|
||||
const domainEvents = require('@tryghost/domain-events');
|
||||
const WelcomeEmailAutomationsService = require('./server/services/welcome-email-automations');
|
||||
new WelcomeEmailAutomationsService().init(domainEvents);
|
||||
|
||||
debug('End: initBackgroundServices');
|
||||
}
|
||||
|
||||
/**
|
||||
* ----------------------------------
|
||||
* Boot Ghost - The magic starts here
|
||||
* ----------------------------------
|
||||
*
|
||||
* - This function is written with async/await so you can read, line by line, what happens on boot
|
||||
* - All the functions above handle init/boot logic for a single component
|
||||
|
||||
* @returns {Promise<object>} ghostServer
|
||||
*/
|
||||
async function bootGhost({backend = true, frontend = true, server = true} = {}) {
|
||||
// Metrics
|
||||
const startTime = Date.now();
|
||||
debug('Begin Boot');
|
||||
|
||||
// We need access to these variables in both the try and catch block
|
||||
let bootLogger;
|
||||
let config;
|
||||
let ghostServer;
|
||||
let logging;
|
||||
let metrics;
|
||||
|
||||
// These require their own try-catch block and error format, because we can't log an error if logging isn't working
|
||||
try {
|
||||
// Step 0 - Load config and logging - fundamental required components
|
||||
// Version is required by logging, sentry & Migration config & so is fundamental to booting
|
||||
// However, it involves reading package.json so its slow & it's here for visibility on that slowness
|
||||
debug('Begin: Load version info');
|
||||
require('@tryghost/version');
|
||||
debug('End: Load version info');
|
||||
|
||||
// Loading config must be the first thing we do, because it is required for absolutely everything
|
||||
debug('Begin: Load config');
|
||||
config = require('./shared/config');
|
||||
debug('End: Load config');
|
||||
|
||||
// Logging is also used absolutely everywhere
|
||||
debug('Begin: Load logging');
|
||||
logging = require('@tryghost/logging');
|
||||
metrics = require('@tryghost/metrics');
|
||||
bootLogger = new BootLogger(logging, metrics, startTime);
|
||||
debug('End: Load logging');
|
||||
|
||||
// At this point logging is required, so we can handle errors better
|
||||
|
||||
// Add a process handler to capture and log unhandled rejections
|
||||
debug('Begin: Add unhandled rejection handler');
|
||||
process.on('unhandledRejection', (error) => {
|
||||
logging.error('Unhandled rejection:', error);
|
||||
});
|
||||
debug('End: Add unhandled rejection handler');
|
||||
} catch (error) {
|
||||
console.error(error); // eslint-disable-line no-console
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
try {
|
||||
// Step 1 - require more fundamental components
|
||||
|
||||
// Sentry must be initialized early, but requires config
|
||||
debug('Begin: Load sentry');
|
||||
const sentry = require('./shared/sentry');
|
||||
debug('End: Load sentry');
|
||||
|
||||
// Initialize prometheus client early to enable metrics collection during boot
|
||||
// Note: this does not start the metrics server yet to avoid increasing boot time
|
||||
const prometheusClient = initPrometheusClient({config});
|
||||
|
||||
// Step 2 - Start server with minimal app in global maintenance mode
|
||||
debug('Begin: load server + minimal app');
|
||||
const rootApp = require('./app')();
|
||||
|
||||
if (server) {
|
||||
const GhostServer = require('./server/ghost-server');
|
||||
ghostServer = new GhostServer({url: config.getSiteUrl(), env: config.get('env'), serverConfig: config.get('server')});
|
||||
await ghostServer.start(rootApp);
|
||||
bootLogger.log('server started');
|
||||
|
||||
// Ensure the prometheus client is stopped when the server shuts down
|
||||
ghostServer.registerCleanupTask(async () => {
|
||||
if (prometheusClient) {
|
||||
prometheusClient.stop();
|
||||
}
|
||||
});
|
||||
debug('End: load server + minimal app');
|
||||
}
|
||||
|
||||
// Step 3 - Get the DB ready
|
||||
debug('Begin: Get DB ready');
|
||||
await initDatabase({config});
|
||||
bootLogger.log('database ready');
|
||||
const connection = require('./server/data/db/connection');
|
||||
sentry.initQueryTracing(
|
||||
connection
|
||||
);
|
||||
debug('End: Get DB ready');
|
||||
|
||||
// Step 4 - Load Ghost with all its services
|
||||
debug('Begin: Load Ghost Services & Apps');
|
||||
await initCore({ghostServer, config, frontend});
|
||||
|
||||
// Instrument the knex instance and connection pool if prometheus is enabled
|
||||
// Needs to be after initCore because the pool is destroyed and recreated in initCore, which removes the event listeners
|
||||
if (prometheusClient) {
|
||||
prometheusClient.instrumentKnex(connection);
|
||||
}
|
||||
|
||||
const {dataService} = await initServicesForFrontend({bootLogger});
|
||||
|
||||
if (frontend) {
|
||||
await initFrontend(dataService);
|
||||
}
|
||||
const ghostApp = await initExpressApps({frontend, backend, config});
|
||||
|
||||
if (frontend) {
|
||||
await initDynamicRouting();
|
||||
await initAppService();
|
||||
}
|
||||
|
||||
await initServices();
|
||||
debug('End: Load Ghost Services & Apps');
|
||||
|
||||
// Step 5 - Mount the full Ghost app onto the minimal root app & disable maintenance mode
|
||||
debug('Begin: mountGhost');
|
||||
rootApp.disable('maintenance');
|
||||
rootApp.use(config.getSubdir(), ghostApp);
|
||||
debug('End: mountGhost');
|
||||
|
||||
// Step 6 - We are technically done here - let everyone know!
|
||||
bootLogger.log('booted');
|
||||
bootLogger.metric('boot-time');
|
||||
notifyServerReady();
|
||||
|
||||
// Step 7 - Init our background services, we don't wait for this to finish
|
||||
initBackgroundServices({config});
|
||||
|
||||
// If we pass the env var, kill Ghost
|
||||
if (process.env.GHOST_CI_SHUTDOWN_AFTER_BOOT) {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// We return the server purely for testing purposes
|
||||
if (server) {
|
||||
debug('End Boot: Returning Ghost Server');
|
||||
return ghostServer;
|
||||
} else {
|
||||
debug('End boot: Returning Root App');
|
||||
return rootApp;
|
||||
}
|
||||
} catch (error) {
|
||||
const errors = require('@tryghost/errors');
|
||||
|
||||
// Ensure the error we have is an ignition error
|
||||
let serverStartError = error;
|
||||
if (!errors.utils.isGhostError(serverStartError)) {
|
||||
serverStartError = new errors.InternalServerError({message: serverStartError.message, err: serverStartError});
|
||||
}
|
||||
|
||||
logging.error(serverStartError);
|
||||
|
||||
// If ghost was started and something else went wrong, we shut it down
|
||||
if (ghostServer) {
|
||||
notifyServerReady(serverStartError);
|
||||
ghostServer.shutdown(2);
|
||||
} else {
|
||||
// Ghost server failed to start, set a timeout to give logging a chance to flush
|
||||
setTimeout(() => {
|
||||
process.exit(2);
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = bootGhost;
|
||||
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* The Bridge
|
||||
*
|
||||
* The bridge is responsible for handing communication from the server to the frontend.
|
||||
* Data should only be flowing server -> frontend.
|
||||
* As the architecture improves, the number of cross requires here should go down
|
||||
* Eventually, the aim is to make this a component that is initialized on boot and is either handed to or actively creates the frontend, if the frontend is desired.
|
||||
*
|
||||
* This file is a great place for all the cross-component event handling in lieu of refactoring
|
||||
* NOTE: You may require anything from shared, the frontend or server here - it is the one place (other than boot) that is allowed :)
|
||||
*/
|
||||
|
||||
const debug = require('@tryghost/debug')('bridge');
|
||||
const errors = require('@tryghost/errors');
|
||||
const logging = require('@tryghost/logging');
|
||||
const tpl = require('@tryghost/tpl');
|
||||
const themeEngine = require('./frontend/services/theme-engine');
|
||||
const appService = require('./frontend/services/apps');
|
||||
const {adminAuthAssets, cardAssets} = require('./frontend/services/assets-minification');
|
||||
const routerManager = require('./frontend/services/routing').routerManager;
|
||||
const settingsCache = require('./shared/settings-cache');
|
||||
const urlService = require('./server/services/url');
|
||||
const routeSettings = require('./server/services/route-settings');
|
||||
const labs = require('./shared/labs');
|
||||
|
||||
// Listen to settings.locale.edited, similar to the member service and models/base/listeners
|
||||
const events = require('./server/lib/common/events');
|
||||
|
||||
const messages = {
|
||||
activateFailed: 'Unable to activate the theme "{theme}".'
|
||||
};
|
||||
|
||||
class Bridge {
|
||||
constructor() {
|
||||
// Track the previous state of the themeTranslation flag
|
||||
this.previousThemeTranslationState = labs.isSet('themeTranslation');
|
||||
}
|
||||
|
||||
init() {
|
||||
/**
|
||||
* When locale changes, we reload theme translations
|
||||
*/
|
||||
events.on('settings.locale.edited', (model) => {
|
||||
debug('locale changed, updating i18n to', model.get('value'));
|
||||
this.getActiveTheme().initI18n({locale: model.get('value')});
|
||||
});
|
||||
|
||||
events.on('settings.labs.edited', () => {
|
||||
const currentThemeTranslationState = labs.isSet('themeTranslation');
|
||||
|
||||
if (currentThemeTranslationState !== this.previousThemeTranslationState) {
|
||||
debug('themeTranslation flag changed from %s to %s',
|
||||
this.previousThemeTranslationState, currentThemeTranslationState);
|
||||
const locale = settingsCache.get('locale');
|
||||
this.getActiveTheme().initI18n({locale});
|
||||
|
||||
// Update the tracked state
|
||||
this.previousThemeTranslationState = currentThemeTranslationState;
|
||||
}
|
||||
});
|
||||
|
||||
// NOTE: eventually this event should somehow be listened on and handled by the URL Service
|
||||
// for now this eliminates the need for the frontend routing to listen to
|
||||
// server events
|
||||
events.on('settings.timezone.edited', (model) => {
|
||||
routerManager.handleTimezoneEdit(model);
|
||||
});
|
||||
}
|
||||
|
||||
getActiveTheme() {
|
||||
return themeEngine.getActive();
|
||||
}
|
||||
|
||||
ensureAdminAuthAssetsMiddleware() {
|
||||
return adminAuthAssets.serveMiddleware();
|
||||
}
|
||||
|
||||
async activateTheme(loadedTheme, checkedTheme) {
|
||||
let settings = {
|
||||
locale: settingsCache.get('locale')
|
||||
};
|
||||
// no need to check the score, activation should be used in combination with validate.check
|
||||
// Use the two theme objects to set the current active theme
|
||||
try {
|
||||
themeEngine.setActive(settings, loadedTheme, checkedTheme);
|
||||
|
||||
logging.info('Invalidating assets for regeneration');
|
||||
|
||||
const cardAssetConfig = this.getCardAssetConfig();
|
||||
debug('reload card assets config', cardAssetConfig);
|
||||
cardAssets.invalidate(cardAssetConfig);
|
||||
|
||||
// rebuild asset files
|
||||
adminAuthAssets.invalidate();
|
||||
} catch (err) {
|
||||
logging.error(new errors.InternalServerError({
|
||||
message: tpl(messages.activateFailed, {theme: loadedTheme.name}),
|
||||
err: err
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
getCardAssetConfig() {
|
||||
if (this.getActiveTheme()) {
|
||||
return this.getActiveTheme().config('card_assets');
|
||||
} else {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
async reloadFrontend() {
|
||||
debug('reload frontend');
|
||||
const siteApp = require('./frontend/web/site');
|
||||
|
||||
const routerConfig = {
|
||||
routeSettings: await routeSettings.loadRouteSettings(),
|
||||
urlService
|
||||
};
|
||||
|
||||
await siteApp.reload(routerConfig);
|
||||
|
||||
// re-initialize apps (register app routers, because we have re-initialized the site routers)
|
||||
appService.init();
|
||||
|
||||
// connect routers and resources again
|
||||
urlService.queue.start({
|
||||
event: 'init',
|
||||
tolerance: 100,
|
||||
requiredSubscriberCount: 1
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const bridge = new Bridge();
|
||||
|
||||
module.exports = bridge;
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Internal CLI Placeholder
|
||||
*
|
||||
* If we want to add alternative commands, flags, or modify environment vars, it should all go here.
|
||||
* Important: This file should not contain any requires, unless we decide to add pretty-cli/commander type tools
|
||||
*
|
||||
**/
|
||||
|
||||
// Don't allow NODE_ENV to be null
|
||||
process.env.NODE_ENV = process.env.NODE_ENV || 'development';
|
||||
|
||||
const argv = process.argv;
|
||||
const mode = argv[2];
|
||||
|
||||
// Switch between boot modes
|
||||
switch (mode) {
|
||||
case 'repl':
|
||||
case 'timetravel':
|
||||
case 'generate-data':
|
||||
require('./core/cli/command').run(mode);
|
||||
break;
|
||||
default:
|
||||
// New boot sequence
|
||||
require('./core/boot')();
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
require('./ghost');
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"include": ["core/**/*.js", "test/**/*.js"],
|
||||
"compilerOptions": {
|
||||
"checkJs": true,
|
||||
"module": "commonjs",
|
||||
"target": "es2018",
|
||||
"moduleResolution": "node"
|
||||
},
|
||||
"exclude": [
|
||||
"core/built"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
const config = require('./core/shared/config');
|
||||
const ghostVersion = require('@tryghost/version');
|
||||
|
||||
// Config for logging
|
||||
const loggingConfig = config.get('logging') || {};
|
||||
|
||||
if (!loggingConfig.path) {
|
||||
loggingConfig.path = config.getContentPath('logs');
|
||||
}
|
||||
|
||||
// Additional values used by logging
|
||||
loggingConfig.env = config.get('env');
|
||||
loggingConfig.domain = config.get('url');
|
||||
loggingConfig.metadata = {
|
||||
version: ghostVersion.original
|
||||
};
|
||||
|
||||
// Config for metrics
|
||||
loggingConfig.metrics = config.get('logging:metrics') || {};
|
||||
loggingConfig.metrics.metadata = {
|
||||
// Undefined if unavailable
|
||||
siteId: config.get('hostSettings:siteId'),
|
||||
domain: config.get('url'),
|
||||
version: ghostVersion.original
|
||||
};
|
||||
|
||||
module.exports = loggingConfig;
|
||||
Executable
+235
@@ -0,0 +1,235 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/* eslint-disable no-console */
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const concurrently = require('concurrently');
|
||||
const detectIndent = require('detect-indent');
|
||||
const detectNewline = require('detect-newline');
|
||||
const findRoot = require('find-root');
|
||||
const {flattenDeep} = require('lodash');
|
||||
const glob = require('glob');
|
||||
|
||||
const DETECT_TRAILING_WHITESPACE = /\s+$/;
|
||||
|
||||
const jsonFiles = new Map();
|
||||
|
||||
class JSONFile {
|
||||
/**
|
||||
* @param {string} filePath
|
||||
* @returns {JSONFile}
|
||||
*/
|
||||
static for(filePath) {
|
||||
if (jsonFiles.has(filePath)) {
|
||||
return jsonFiles.get(filePath);
|
||||
}
|
||||
|
||||
let jsonFile = new this(filePath);
|
||||
jsonFiles.set(filePath, jsonFile);
|
||||
|
||||
return jsonFile;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} filename
|
||||
*/
|
||||
constructor(filename) {
|
||||
this.filename = filename;
|
||||
this.reload();
|
||||
}
|
||||
|
||||
reload() {
|
||||
const contents = fs.readFileSync(this.filename, {encoding: 'utf8'});
|
||||
|
||||
this.pkg = JSON.parse(contents);
|
||||
this.lineEndings = detectNewline(contents);
|
||||
this.indent = detectIndent(contents).amount;
|
||||
|
||||
let trailingWhitespace = DETECT_TRAILING_WHITESPACE.exec(contents);
|
||||
this.trailingWhitespace = trailingWhitespace ? trailingWhitespace : '';
|
||||
}
|
||||
|
||||
write() {
|
||||
let contents = JSON.stringify(this.pkg, null, this.indent).replace(/\n/g, this.lineEndings);
|
||||
|
||||
fs.writeFileSync(this.filename, contents + this.trailingWhitespace, {encoding: 'utf8'});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read workspace package globs from pnpm-workspace.yaml.
|
||||
* @param {string} dir
|
||||
* @returns {string[]|null}
|
||||
*/
|
||||
function getPackages(dir) {
|
||||
const pnpmWorkspace = path.join(dir, 'pnpm-workspace.yaml');
|
||||
if (!fs.existsSync(pnpmWorkspace)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const content = fs.readFileSync(pnpmWorkspace, 'utf8');
|
||||
const packages = [];
|
||||
let inPackages = false;
|
||||
for (const line of content.split('\n')) {
|
||||
if (line.startsWith('packages:')) {
|
||||
inPackages = true;
|
||||
continue;
|
||||
}
|
||||
if (inPackages) {
|
||||
const match = line.match(/^\s+-\s+['"]?([^'"]+)['"]?\s*$/);
|
||||
if (match) {
|
||||
packages.push(match[1]);
|
||||
} else if (/^\S/.test(line)) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return packages.length > 0 ? packages : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} from
|
||||
* @returns {string[]}
|
||||
*/
|
||||
function getWorkspaces(from) {
|
||||
const root = findRoot(from, (dir) => {
|
||||
return getPackages(dir) !== null;
|
||||
});
|
||||
|
||||
const packages = getPackages(root);
|
||||
return flattenDeep(packages.map(name => glob.sync(path.join(root, `${name}/`))));
|
||||
}
|
||||
|
||||
(async () => {
|
||||
const cwd = process.cwd();
|
||||
const nearestPkgJson = findRoot(cwd);
|
||||
console.log('nearestPkgJson', nearestPkgJson);
|
||||
const pkgInfo = JSONFile.for(path.join(nearestPkgJson, 'package.json'));
|
||||
|
||||
if (pkgInfo.pkg.name !== 'ghost') {
|
||||
console.log('This script must be run from the `ghost` npm package directory');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const bundlePath = './components';
|
||||
if (!fs.existsSync(bundlePath)){
|
||||
fs.mkdirSync(bundlePath);
|
||||
}
|
||||
|
||||
const workspaces = getWorkspaces(cwd)
|
||||
.filter(w => !w.startsWith(cwd) && fs.existsSync(path.join(w, 'package.json')))
|
||||
.filter(w => !w.includes('apps/'))
|
||||
.filter(w => !w.includes('/admin/'))
|
||||
.filter(w => !w.includes('/e2e/'));
|
||||
|
||||
console.log('workspaces', workspaces);
|
||||
console.log('\n-------------------------\n');
|
||||
|
||||
const packagesToPack = [];
|
||||
|
||||
for (const w of workspaces) {
|
||||
const workspacePkgInfo = JSONFile.for(path.join(w, 'package.json'));
|
||||
|
||||
if (!workspacePkgInfo.pkg.private) {
|
||||
continue;
|
||||
}
|
||||
|
||||
workspacePkgInfo.pkg.version = pkgInfo.pkg.version;
|
||||
workspacePkgInfo.write();
|
||||
|
||||
const slugifiedName = workspacePkgInfo.pkg.name.replace(/@/g, '').replace(/\//g, '-');
|
||||
const packedFilename = `file:` + path.join(bundlePath, `${slugifiedName}-${workspacePkgInfo.pkg.version}.tgz`);
|
||||
|
||||
if (pkgInfo.pkg.dependencies[workspacePkgInfo.pkg.name]) {
|
||||
console.log(`[${workspacePkgInfo.pkg.name}] dependencies override => ${packedFilename}`);
|
||||
pkgInfo.pkg.dependencies[workspacePkgInfo.pkg.name] = packedFilename;
|
||||
}
|
||||
|
||||
if (pkgInfo.pkg.devDependencies[workspacePkgInfo.pkg.name]) {
|
||||
console.log(`[${workspacePkgInfo.pkg.name}] devDependencies override => ${packedFilename}`);
|
||||
pkgInfo.pkg.devDependencies[workspacePkgInfo.pkg.name] = packedFilename;
|
||||
}
|
||||
|
||||
if (pkgInfo.pkg.optionalDependencies[workspacePkgInfo.pkg.name]) {
|
||||
console.log(`[${workspacePkgInfo.pkg.name}] optionalDependencies override => ${packedFilename}`);
|
||||
pkgInfo.pkg.optionalDependencies[workspacePkgInfo.pkg.name] = packedFilename;
|
||||
}
|
||||
|
||||
console.log(`[${workspacePkgInfo.pkg.name}] resolution override => ${packedFilename}\n`);
|
||||
if (!pkgInfo.pkg.resolutions) {
|
||||
pkgInfo.pkg.resolutions = {};
|
||||
}
|
||||
pkgInfo.pkg.resolutions[workspacePkgInfo.pkg.name] = packedFilename;
|
||||
|
||||
packagesToPack.push(w);
|
||||
}
|
||||
|
||||
// Copy pnpm.overrides from root package.json so production installs
|
||||
// pin the same transitive dependency versions as the workspace.
|
||||
const rootPkgPath = path.join(findRoot(path.dirname(nearestPkgJson)), 'package.json');
|
||||
const rootPkg = JSON.parse(fs.readFileSync(rootPkgPath, 'utf8'));
|
||||
if (rootPkg.pnpm?.overrides) {
|
||||
const workspaceNames = new Set(workspaces
|
||||
.map((w) => {
|
||||
const wpkg = path.join(w, 'package.json');
|
||||
return fs.existsSync(wpkg) ? JSON.parse(fs.readFileSync(wpkg, 'utf8')).name : null;
|
||||
})
|
||||
.filter(Boolean));
|
||||
|
||||
const filteredOverrides = {};
|
||||
for (const [key, value] of Object.entries(rootPkg.pnpm.overrides)) {
|
||||
if (!workspaceNames.has(key)) {
|
||||
filteredOverrides[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
if (!pkgInfo.pkg.pnpm) {
|
||||
pkgInfo.pkg.pnpm = {};
|
||||
}
|
||||
pkgInfo.pkg.pnpm.overrides = {...filteredOverrides, ...pkgInfo.pkg.pnpm.overrides};
|
||||
}
|
||||
|
||||
// Copy onlyBuiltDependencies so native addons can run install scripts.
|
||||
if (rootPkg.pnpm?.onlyBuiltDependencies) {
|
||||
if (!pkgInfo.pkg.pnpm) {
|
||||
pkgInfo.pkg.pnpm = {};
|
||||
}
|
||||
pkgInfo.pkg.pnpm.onlyBuiltDependencies = rootPkg.pnpm.onlyBuiltDependencies;
|
||||
}
|
||||
|
||||
// Copy packageManager so corepack uses the correct pnpm version.
|
||||
if (rootPkg.packageManager) {
|
||||
pkgInfo.pkg.packageManager = rootPkg.packageManager;
|
||||
}
|
||||
|
||||
pkgInfo.write();
|
||||
|
||||
const {result} = concurrently(packagesToPack.map(w => ({
|
||||
name: w,
|
||||
cwd: w,
|
||||
command: 'npm pack --pack-destination ../core/components'
|
||||
})));
|
||||
|
||||
try {
|
||||
await result;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
throw e;
|
||||
}
|
||||
|
||||
const filesToCopy = [
|
||||
'README.md',
|
||||
'LICENSE',
|
||||
'pnpm-lock.yaml',
|
||||
'pnpm-workspace.yaml',
|
||||
'.npmrc'
|
||||
];
|
||||
|
||||
for (const file of filesToCopy) {
|
||||
console.log(`copying ../../${file} to ${file}`);
|
||||
fs.copyFileSync(path.join('../../', file), file);
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"watch": [
|
||||
".",
|
||||
"../*"
|
||||
],
|
||||
"ignore": [
|
||||
"../admin/**",
|
||||
"content/**",
|
||||
"core/built/**"
|
||||
],
|
||||
"ext": "js,mjs,cjs,json,ts,tsx",
|
||||
"exec": "node --import=tsx"
|
||||
}
|
||||
@@ -0,0 +1,346 @@
|
||||
{
|
||||
"name": "ghost",
|
||||
"version": "6.33.0-rc.0",
|
||||
"description": "The professional publishing platform",
|
||||
"author": "Ghost Foundation",
|
||||
"homepage": "https://ghost.org",
|
||||
"keywords": [
|
||||
"ghost",
|
||||
"blog",
|
||||
"cms",
|
||||
"headless",
|
||||
"content",
|
||||
"markdown"
|
||||
],
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git://github.com/TryGhost/Ghost.git"
|
||||
},
|
||||
"bugs": "https://github.com/TryGhost/Ghost/issues",
|
||||
"contributors": "https://github.com/TryGhost/Ghost/graphs/contributors",
|
||||
"license": "MIT",
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"registry": "https://registry.npmjs.org/"
|
||||
},
|
||||
"scripts": {
|
||||
"archive": "pnpm pack:standalone && pnpm pack:tarball",
|
||||
"pack:standalone": "rm -rf package; find . -maxdepth 1 -name 'ghost-*.tgz' -delete; npm pack && tar xzf ghost-*.tgz && cp ../../pnpm-lock.yaml package/ && cp ../../.npmrc package/.npmrc && echo '\nfrozen-lockfile=false\nshamefully-hoist=true' >> package/.npmrc && rm ghost-*.tgz && rm -f pnpm-lock.yaml pnpm-workspace.yaml .npmrc",
|
||||
"pack:tarball": "tar czf ghost-$(node -p \"require('./package.json').version\").tgz package",
|
||||
"dev": "nodemon index.js",
|
||||
"build:assets": "pnpm build:assets:css && pnpm build:assets:js",
|
||||
"build:assets:js": "node bin/minify-assets.js",
|
||||
"generate-golden-email": "node bin/generate-golden-email.js",
|
||||
"migrate:create": "node bin/create-migration.js",
|
||||
"build:assets:css": "postcss core/frontend/public/ghost.css --no-map --use cssnano -o core/frontend/public/ghost.min.css",
|
||||
"build:tsc": "tsc",
|
||||
"pretest": "pnpm build:assets",
|
||||
"test": "pnpm test:unit",
|
||||
"test:base": "mocha --reporter dot --node-option import=tsx --require=./test/utils/overrides.js --exit --trace-warnings --recursive --extension=test.js,test.ts",
|
||||
"test:single": "f() { q=$(echo \"$1\" | sed 's/\\.test\\.[jt]s$//'); case \"$q\" in */*) pnpm test:base --timeout=60000 \"$1\" ;; *) pnpm test:base --timeout=60000 \"test/**/*$q*.test.{js,ts}\" ;; esac; }; f",
|
||||
"test:all": "pnpm test:unit && pnpm test:integration && pnpm test:e2e && pnpm lint",
|
||||
"test:debug": "DEBUG=ghost:test* pnpm test",
|
||||
"test:unit": "c8 pnpm test:unit:${GHOST_UNIT_TEST_VARIANT:-base}",
|
||||
"test:unit:base": "pnpm test:base './test/unit' --timeout=2000",
|
||||
"test:unit:ci": "pnpm test:unit:base --reporter=min",
|
||||
"test:integration": "pnpm test:base './test/integration' --timeout=10000",
|
||||
"test:e2e": "pnpm test:base ./test/e2e-* --timeout=15000",
|
||||
"test:legacy": "pnpm test:base './test/legacy' --timeout=60000",
|
||||
"test:ci:e2e": "c8 -c ./.c8rc.e2e.json -o coverage-e2e pnpm test:e2e -b",
|
||||
"test:ci:legacy": "pnpm test:legacy -b",
|
||||
"test:ci:integration": "c8 -c ./.c8rc.e2e.json -o coverage-integration --lines 52 --functions 47 --branches 73 --statements 52 pnpm test:integration -b",
|
||||
"test:unit:slow": "pnpm test:unit --reporter=mocha-slow-test-reporter",
|
||||
"test:int:slow": "pnpm test:integration --reporter=mocha-slow-test-reporter",
|
||||
"test:e2e:slow": "pnpm test:e2e --reporter=mocha-slow-test-reporter",
|
||||
"test:leg:slow": "mocha --reporter dot --require=./test/utils/overrides.js --exit --trace-warnings --recursive --extension=test.js './test/legacy' --timeout=60000 --reporter=mocha-slow-test-reporter",
|
||||
"lint:server": "eslint --ignore-path .eslintignore 'core/server/**/*.js' 'core/*.js' '*.js' --cache",
|
||||
"lint:shared": "eslint --ignore-path .eslintignore 'core/shared/**/*.js' --cache",
|
||||
"lint:frontend": "eslint --ignore-path .eslintignore 'core/frontend/**/*.js' --cache",
|
||||
"lint:test": "eslint -c test/.eslintrc.js --ignore-path test/.eslintignore 'test/**/*.js' --cache",
|
||||
"lint:code": "pnpm lint:server && pnpm lint:shared && pnpm lint:frontend",
|
||||
"lint:types": "eslint --ignore-path .eslintignore '**/*.ts' --cache && tsc --noEmit",
|
||||
"lint": "pnpm lint:server && pnpm lint:shared && pnpm lint:frontend && pnpm lint:test && pnpm lint:types",
|
||||
"prepack": "node monobundle.js",
|
||||
"postpack": "cd ../.. && git checkout -- ghost/core/package.json ghost/i18n/package.json ghost/parse-email-address/package.json && rm -f ghost/core/pnpm-lock.yaml ghost/core/pnpm-workspace.yaml ghost/core/.npmrc && rm -rf ghost/core/components"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^22.13.1",
|
||||
"cli": "^1.29.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "3.1025.0",
|
||||
"@extractus/oembed-extractor": "3.2.1",
|
||||
"@faker-js/faker": "7.6.0",
|
||||
"@isaacs/ttlcache": "1.4.1",
|
||||
"@sentry/node": "7.120.4",
|
||||
"@slack/webhook": "7.0.9",
|
||||
"@tryghost/adapter-base-cache": "0.1.23",
|
||||
"@tryghost/admin-api-schema": "4.7.2",
|
||||
"@tryghost/api-framework": "1.0.7",
|
||||
"@tryghost/bookshelf-plugins": "2.0.3",
|
||||
"@tryghost/color-utils": "0.2.16",
|
||||
"@tryghost/config-url-helpers": "1.0.23",
|
||||
"@tryghost/custom-fonts": "1.0.8",
|
||||
"@tryghost/database-info": "0.3.35",
|
||||
"@tryghost/debug": "0.1.40",
|
||||
"@tryghost/domain-events": "1.0.8",
|
||||
"@tryghost/email-mock-receiver": "0.3.16",
|
||||
"@tryghost/errors": "1.3.13",
|
||||
"@tryghost/helpers": "1.1.103",
|
||||
"@tryghost/html-to-plaintext": "1.0.8",
|
||||
"@tryghost/http-cache-utils": "0.1.25",
|
||||
"@tryghost/i18n": "workspace:*",
|
||||
"@tryghost/image-transform": "1.4.13",
|
||||
"@tryghost/job-manager": "1.0.9",
|
||||
"@tryghost/kg-card-factory": "5.1.14",
|
||||
"@tryghost/kg-clean-basic-html": "4.2.23",
|
||||
"@tryghost/kg-converters": "1.1.21",
|
||||
"@tryghost/kg-default-atoms": "5.1.9",
|
||||
"@tryghost/kg-default-cards": "10.2.13",
|
||||
"@tryghost/kg-default-nodes": "2.0.21",
|
||||
"@tryghost/kg-default-transforms": "1.2.44",
|
||||
"@tryghost/kg-html-to-lexical": "1.2.45",
|
||||
"@tryghost/kg-lexical-html-renderer": "1.3.44",
|
||||
"@tryghost/kg-markdown-html-renderer": "7.1.18",
|
||||
"@tryghost/kg-mobiledoc-html-renderer": "7.1.18",
|
||||
"@tryghost/limit-service": "1.5.2",
|
||||
"@tryghost/logging": "2.5.5",
|
||||
"@tryghost/members-csv": "2.0.5",
|
||||
"@tryghost/metrics": "1.0.43",
|
||||
"@tryghost/mongo-utils": "0.6.3",
|
||||
"@tryghost/mw-error-handler": "1.0.13",
|
||||
"@tryghost/mw-vhost": "1.0.6",
|
||||
"@tryghost/nodemailer": "0.3.48",
|
||||
"@tryghost/nql": "0.12.10",
|
||||
"@tryghost/nql-lang": "0.6.4",
|
||||
"@tryghost/parse-email-address": "workspace:*",
|
||||
"@tryghost/pretty-cli": "1.2.52",
|
||||
"@tryghost/prometheus-metrics": "1.0.8",
|
||||
"@tryghost/promise": "0.3.20",
|
||||
"@tryghost/referrer-parser": "0.1.15",
|
||||
"@tryghost/request": "1.0.12",
|
||||
"@tryghost/root-utils": "0.3.38",
|
||||
"@tryghost/security": "1.0.6",
|
||||
"@tryghost/social-urls": "0.1.60",
|
||||
"@tryghost/string": "0.3.2",
|
||||
"@tryghost/tpl": "0.1.40",
|
||||
"@tryghost/url-utils": "5.1.2",
|
||||
"@tryghost/validator": "0.2.22",
|
||||
"@tryghost/version": "0.1.38",
|
||||
"@tryghost/zip": "1.1.54",
|
||||
"body-parser": "1.20.4",
|
||||
"bookshelf": "1.2.0",
|
||||
"bookshelf-relations": "2.8.0",
|
||||
"brute-knex": "4.0.1",
|
||||
"bson-objectid": "2.0.4",
|
||||
"cache-manager": "4.1.0",
|
||||
"cache-manager-ioredis": "2.1.0",
|
||||
"chalk": "4.1.2",
|
||||
"charset": "1.0.1",
|
||||
"cheerio": "0.22.0",
|
||||
"clsx": "2.1.1",
|
||||
"cluster-key-slot": "1.1.2",
|
||||
"common-tags": "1.8.2",
|
||||
"compression": "1.8.1",
|
||||
"connect-slashes": "1.4.0",
|
||||
"cookie-session": "2.1.1",
|
||||
"cookies": "0.9.1",
|
||||
"cors": "2.8.6",
|
||||
"countries-and-timezones": "3.8.0",
|
||||
"csso": "5.0.5",
|
||||
"csv-writer": "1.6.0",
|
||||
"date-fns": "2.30.0",
|
||||
"dompurify": "3.3.0",
|
||||
"downsize": "0.0.8",
|
||||
"entities": "4.5.0",
|
||||
"express": "4.21.2",
|
||||
"express-brute": "1.0.1",
|
||||
"express-hbs": "2.5.0",
|
||||
"express-jwt": "8.5.1",
|
||||
"express-lazy-router": "1.0.6",
|
||||
"express-query-boolean": "2.0.0",
|
||||
"express-queue": "0.0.13",
|
||||
"express-session": "1.19.0",
|
||||
"file-type": "16.5.4",
|
||||
"form-data": "4.0.5",
|
||||
"fs-extra": "11.3.4",
|
||||
"ghost-storage-base": "1.1.2",
|
||||
"glob": "8.1.0",
|
||||
"got": "13.0.0",
|
||||
"gscan": "5.4.3",
|
||||
"handlebars": "4.7.9",
|
||||
"heic-convert": "2.1.0",
|
||||
"html-to-text": "5.1.1",
|
||||
"html5parser": "2.0.2",
|
||||
"human-number": "2.0.10",
|
||||
"iconv-lite": "0.7.2",
|
||||
"image-size": "1.2.1",
|
||||
"intl": "1.2.5",
|
||||
"intl-messageformat": "5.4.3",
|
||||
"js-yaml": "4.1.1",
|
||||
"jsdom": "28.1.0",
|
||||
"jsonc-parser": "3.3.1",
|
||||
"jsonwebtoken": "8.5.1",
|
||||
"juice": "9.1.0",
|
||||
"keypair": "1.0.4",
|
||||
"knex": "2.4.2",
|
||||
"knex-migrator": "5.3.2",
|
||||
"leaky-bucket": "2.2.0",
|
||||
"lodash": "4.17.23",
|
||||
"luxon": "3.7.2",
|
||||
"mailgun.js": "10.4.0",
|
||||
"metascraper": "5.45.15",
|
||||
"metascraper-author": "5.45.10",
|
||||
"metascraper-description": "5.45.10",
|
||||
"metascraper-image": "5.45.10",
|
||||
"metascraper-logo": "5.45.10",
|
||||
"metascraper-logo-favicon": "5.42.0",
|
||||
"metascraper-publisher": "5.45.10",
|
||||
"metascraper-title": "5.45.10",
|
||||
"metascraper-url": "5.45.10",
|
||||
"mime-types": "2.1.35",
|
||||
"mingo": "2.5.3",
|
||||
"moment": "2.24.0",
|
||||
"moment-timezone": "0.5.45",
|
||||
"multer": "2.0.2",
|
||||
"mysql2": "3.18.1",
|
||||
"nconf": "0.13.0",
|
||||
"node-fetch": "2.7.0",
|
||||
"node-jose": "2.2.0",
|
||||
"nodemailer": "6.10.1",
|
||||
"on-headers": "^1.1.0",
|
||||
"otplib": "12.0.1",
|
||||
"papaparse": "5.5.3",
|
||||
"path-match": "1.2.4",
|
||||
"probability-distributions": "0.9.1",
|
||||
"probe-image-size": "7.2.3",
|
||||
"rss": "1.2.2",
|
||||
"sanitize-html": "2.17.0",
|
||||
"semver": "7.7.4",
|
||||
"simple-dom": "1.4.0",
|
||||
"stoppable": "1.1.0",
|
||||
"stripe": "8.222.0",
|
||||
"superagent": "5.3.1",
|
||||
"superagent-throttle": "1.0.1",
|
||||
"terser": "5.46.1",
|
||||
"tiny-glob": "0.2.9",
|
||||
"ua-parser-js": "1.0.41",
|
||||
"xml": "1.0.1"
|
||||
},
|
||||
"overrides": {
|
||||
"lodash.template": "4.5.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@tryghost/html-to-mobiledoc": "3.2.26",
|
||||
"sqlite3": "5.1.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@actions/core": "3.0.0",
|
||||
"@prettier/sync": "0.6.1",
|
||||
"@tryghost/express-test": "0.15.5",
|
||||
"@tryghost/webhook-mock-receiver": "0.2.22",
|
||||
"@types/bookshelf": "1.2.9",
|
||||
"@types/common-tags": "1.8.4",
|
||||
"@types/express": "4.17.25",
|
||||
"@types/jsdom": "28.0.1",
|
||||
"@types/jsonwebtoken": "9.0.10",
|
||||
"@types/lodash": "4.17.24",
|
||||
"@types/lodash-es": "4.17.12",
|
||||
"@types/mime-types": "3.0.1",
|
||||
"@types/mocha": "10.0.10",
|
||||
"@types/node": "22.19.17",
|
||||
"@types/node-fetch": "2.6.13",
|
||||
"@types/node-jose": "1.1.13",
|
||||
"@types/nodemailer": "6.4.23",
|
||||
"@types/on-headers": "1.0.4",
|
||||
"@types/sinon": "17.0.4",
|
||||
"@types/supertest": "6.0.3",
|
||||
"c8": "10.1.3",
|
||||
"cli-progress": "3.12.0",
|
||||
"cssnano": "7.1.1",
|
||||
"detect-indent": "6.1.0",
|
||||
"detect-newline": "3.1.0",
|
||||
"expect": "29.7.0",
|
||||
"find-root": "1.1.0",
|
||||
"form-data": "4.0.5",
|
||||
"html-minifier": "4.0.0",
|
||||
"html-validate": "8.29.0",
|
||||
"inquirer": "8.2.7",
|
||||
"jwk-to-pem": "2.0.7",
|
||||
"jwks-rsa": "3.2.0",
|
||||
"lodash-es": "4.17.23",
|
||||
"mocha": "11.7.5",
|
||||
"mocha-slow-test-reporter": "0.1.2",
|
||||
"mock-knex": "TryGhost/mock-knex#68948e11b0ea4fe63456098dfdc169bea7f62009",
|
||||
"nock": "13.5.6",
|
||||
"nodemon": "3.1.14",
|
||||
"papaparse": "5.5.3",
|
||||
"parse-prometheus-text-format": "1.1.1",
|
||||
"postcss": "8.5.6",
|
||||
"postcss-cli": "11.0.1",
|
||||
"rewire": "9.0.1",
|
||||
"sinon": "18.0.1",
|
||||
"supertest": "6.3.4",
|
||||
"tmp": "0.2.5",
|
||||
"toml": "3.0.0",
|
||||
"tsx": "4.21.0",
|
||||
"typescript": "5.9.3"
|
||||
},
|
||||
"nx": {
|
||||
"targets": {
|
||||
"build:tsc": {
|
||||
"dependsOn": [
|
||||
"^build"
|
||||
]
|
||||
},
|
||||
"archive": {
|
||||
"dependsOn": [
|
||||
"build:assets",
|
||||
"build:tsc",
|
||||
{
|
||||
"projects": [
|
||||
"@tryghost/admin"
|
||||
],
|
||||
"target": "build"
|
||||
}
|
||||
]
|
||||
},
|
||||
"dev": {
|
||||
"dependsOn": [
|
||||
"build:assets"
|
||||
]
|
||||
},
|
||||
"lint": {
|
||||
"dependsOn": [
|
||||
"^build"
|
||||
]
|
||||
},
|
||||
"test:all": {
|
||||
"dependsOn": [
|
||||
"build:assets"
|
||||
]
|
||||
},
|
||||
"test:unit": {
|
||||
"dependsOn": [
|
||||
"build:assets",
|
||||
"^build"
|
||||
]
|
||||
},
|
||||
"test:ci:e2e": {
|
||||
"dependsOn": [
|
||||
"^build"
|
||||
]
|
||||
},
|
||||
"test:ci:legacy": {
|
||||
"dependsOn": [
|
||||
"^build"
|
||||
]
|
||||
},
|
||||
"test:ci:integration": {
|
||||
"dependsOn": [
|
||||
"^build"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
test/coverage/**
|
||||
test/utils/fixtures/themes/casper/assets/**
|
||||
test/utils/fixtures/themes/source/assets/**
|
||||
@@ -0,0 +1,47 @@
|
||||
module.exports = {
|
||||
env: {
|
||||
es6: true,
|
||||
node: true,
|
||||
mocha: true
|
||||
},
|
||||
plugins: [
|
||||
'ghost'
|
||||
],
|
||||
extends: [
|
||||
'eslint:recommended',
|
||||
'plugin:ghost/test'
|
||||
],
|
||||
overrides: [
|
||||
{
|
||||
files: ['**/*.ts'],
|
||||
parser: '@typescript-eslint/parser',
|
||||
extends: [
|
||||
'plugin:ghost/test'
|
||||
]
|
||||
}
|
||||
],
|
||||
rules: {
|
||||
// TODO: remove this rule once it's turned into "error" in the base plugin
|
||||
'no-shadow': 'error',
|
||||
|
||||
// these rules were were not previously enforced in our custom rules,
|
||||
// they're turned off here because they _are_ enforced in our plugin.
|
||||
// TODO: remove these custom rules and fix the problems in test files where appropriate
|
||||
camelcase: 'off',
|
||||
'no-prototype-builtins': 'off',
|
||||
'no-unused-vars': [
|
||||
'error',
|
||||
{
|
||||
varsIgnorePattern: '^should$'
|
||||
}
|
||||
],
|
||||
'no-useless-escape': 'off',
|
||||
|
||||
'ghost/mocha/no-skipped-tests': 'error',
|
||||
'ghost/filenames/match-regex': ['error', '^[a-z0-9-.]+$', null, true],
|
||||
|
||||
// TODO: remove these custom rules and fix problems in test files
|
||||
'ghost/mocha/no-setup-in-describe': 'off',
|
||||
'ghost/mocha/no-sibling-hooks': 'off'
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,107 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
/* Visit https://aka.ms/tsconfig to read more about this file */
|
||||
/* Projects */
|
||||
"incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */
|
||||
// "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */
|
||||
// "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */
|
||||
// "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */
|
||||
// "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */
|
||||
// "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */
|
||||
/* Language and Environment */
|
||||
"target": "es2022", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */
|
||||
// "lib": ["es2019"], /* Specify a set of bundled library declaration files that describe the target runtime environment. */
|
||||
// "jsx": "preserve", /* Specify what JSX code is generated. */
|
||||
// "experimentalDecorators": true, /* Enable experimental support for legacy experimental decorators. */
|
||||
// "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */
|
||||
// "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */
|
||||
// "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */
|
||||
// "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */
|
||||
// "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */
|
||||
// "noLib": true, /* Disable including any library files, including the default lib.d.ts. */
|
||||
// "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */
|
||||
// "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */
|
||||
/* Modules */
|
||||
"module": "commonjs", /* Specify what module code is generated. */
|
||||
//"rootDir": "src", /* Specify the root folder within your source files. */
|
||||
// "moduleResolution": "node10", /* Specify how TypeScript looks up a file from a given module specifier. */
|
||||
// "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */
|
||||
// "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */
|
||||
// "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */
|
||||
// "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */
|
||||
"types": ["node", "mocha"], /* Specify type package names to be included without being referenced in a source file. */
|
||||
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
|
||||
// "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */
|
||||
// "allowImportingTsExtensions": true, /* Allow imports to include TypeScript file extensions. Requires '--moduleResolution bundler' and either '--noEmit' or '--emitDeclarationOnly' to be set. */
|
||||
// "resolvePackageJsonExports": true, /* Use the package.json 'exports' field when resolving package imports. */
|
||||
// "resolvePackageJsonImports": true, /* Use the package.json 'imports' field when resolving imports. */
|
||||
// "customConditions": [], /* Conditions to set in addition to the resolver-specific defaults when resolving imports. */
|
||||
"resolveJsonModule": true, /* Enable importing .json files. */
|
||||
// "allowArbitraryExtensions": true, /* Enable importing files with any extension, provided a declaration file is present. */
|
||||
// "noResolve": true, /* Disallow 'import's, 'require's or '<reference>'s from expanding the number of files TypeScript should add to a project. */
|
||||
/* JavaScript Support */
|
||||
// "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */
|
||||
// "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */
|
||||
// "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */
|
||||
/* Emit */
|
||||
//"declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */
|
||||
// "declarationMap": true, /* Create sourcemaps for d.ts files. */
|
||||
// "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */
|
||||
// "sourceMap": true, /* Create source map files for emitted JavaScript files. */
|
||||
// "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */
|
||||
// "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */
|
||||
//"outDir": "build", /* Specify an output folder for all emitted files. */
|
||||
// "removeComments": true, /* Disable emitting comments. */
|
||||
// "noEmit": true, /* Disable emitting files from a compilation. */
|
||||
// "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */
|
||||
// "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types. */
|
||||
// "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */
|
||||
// "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */
|
||||
// "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
|
||||
// "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */
|
||||
// "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */
|
||||
// "newLine": "crlf", /* Set the newline character for emitting files. */
|
||||
// "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */
|
||||
// "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */
|
||||
// "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */
|
||||
// "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */
|
||||
// "declarationDir": "./", /* Specify the output directory for generated declaration files. */
|
||||
// "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */
|
||||
/* Interop Constraints */
|
||||
// "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */
|
||||
// "verbatimModuleSyntax": true, /* Do not transform or elide any imports or exports not marked as type-only, ensuring they are written in the output file's format based on the 'module' setting. */
|
||||
"erasableSyntaxOnly": true, /* Do not allow runtime constructs that are not part of ECMAScript. */
|
||||
// "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */
|
||||
"esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */
|
||||
// "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */
|
||||
"forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */
|
||||
/* Type Checking */
|
||||
"strict": true, /* Enable all strict type-checking options. */
|
||||
"noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */
|
||||
// "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */
|
||||
// "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */
|
||||
// "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */
|
||||
// "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */
|
||||
// "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */
|
||||
// "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */
|
||||
// "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */
|
||||
// "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */
|
||||
// "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */
|
||||
// "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */
|
||||
// "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */
|
||||
// "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */
|
||||
// "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */
|
||||
// "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */
|
||||
// "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */
|
||||
// "allowUnusedLabels": true, /* Disable error reporting for unused labels. */
|
||||
// "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */
|
||||
/* Completeness */
|
||||
// "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */
|
||||
"skipLibCheck": true /* Skip type checking all .d.ts files. */
|
||||
},
|
||||
"include": [
|
||||
"core/**/*.ts",
|
||||
"test/**/*.ts",
|
||||
"types/**/*.d.ts"
|
||||
]
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
declare module 'ghost-storage-base' {
|
||||
import {RequestHandler} from 'express';
|
||||
|
||||
export type StorageFile = {
|
||||
name: string;
|
||||
path: string;
|
||||
type?: string;
|
||||
};
|
||||
|
||||
export default abstract class StorageBase {
|
||||
protected storagePath: string;
|
||||
getTargetDir(baseDir?: string): string;
|
||||
getUniqueFileName(file: StorageFile, targetDir: string): Promise<string>;
|
||||
abstract save(file: StorageFile, targetDir?: string): Promise<string>;
|
||||
abstract saveRaw(buffer: Buffer, targetPath: string): Promise<string>;
|
||||
abstract exists(fileName: string, targetDir: string): Promise<boolean>;
|
||||
abstract delete(fileName: string, targetDir: string): Promise<void>;
|
||||
abstract read(file: {path: string}): Promise<Buffer>;
|
||||
abstract serve(): RequestHandler;
|
||||
abstract urlToPath(url: string): string;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
module.exports = {
|
||||
plugins: ['ghost'],
|
||||
extends: [
|
||||
'plugin:ghost/node'
|
||||
],
|
||||
rules: {
|
||||
// Enforce kebab-case (lowercase with hyphens) for all filenames
|
||||
'ghost/filenames/match-regex': ['error', '^[a-z0-9.-]+$', false]
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
# i18n
|
||||
|
||||
i18n translations for Ghost
|
||||
|
||||
## Develop
|
||||
|
||||
This is a monorepo package.
|
||||
|
||||
Follow the instructions for the top-level repo.
|
||||
1. `git clone` this repo & `cd` into it as usual
|
||||
2. Run `pnpm` to install top-level dependencies.
|
||||
|
||||
## Test
|
||||
|
||||
- `pnpm lint` run just eslint
|
||||
- `pnpm test` run lint and tests
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
const fs = require('fs').promises;
|
||||
const path = require('path');
|
||||
|
||||
const BASE_PATH = './locales/en';
|
||||
const CONTEXT_FILE = './locales/context.json';
|
||||
|
||||
(async () => {
|
||||
const existingContent = await fs.readFile(CONTEXT_FILE, 'utf-8');
|
||||
const context = JSON.parse(existingContent);
|
||||
|
||||
const newContext = {};
|
||||
|
||||
const files = await fs.readdir(BASE_PATH);
|
||||
|
||||
for (const file of files) {
|
||||
const filePath = path.join(process.cwd(), BASE_PATH, file);
|
||||
const data = require(filePath);
|
||||
|
||||
for (const key of Object.keys(data)) {
|
||||
newContext[key] = context[key] || '';
|
||||
}
|
||||
}
|
||||
|
||||
const orderedContext = Object.keys(newContext).sort().reduce((obj, key) => {
|
||||
obj[key] = newContext[key];
|
||||
return obj;
|
||||
}, {});
|
||||
|
||||
const newContent = JSON.stringify(orderedContext, null, 4);
|
||||
|
||||
if (process.env.CI && newContent !== existingContent) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('context.json is out of date. Run `pnpm translate` in ghost/i18n and commit the result.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const emptyKeys = Object.keys(orderedContext).filter((key) => {
|
||||
const value = orderedContext[key] ?? '';
|
||||
return value.trim() === '';
|
||||
});
|
||||
|
||||
if (emptyKeys.length > 0) {
|
||||
if (process.env.CI) {
|
||||
const keyList = emptyKeys.map(k => ' - "' + k + '"').join('\n');
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('Translation keys are missing context descriptions in context.json:\n' + keyList);
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('\nAdd a description for each key in locales/context.json to help translators understand where and how the string is used.');
|
||||
process.exit(1);
|
||||
} else {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(`Warning: ${emptyKeys.length} key(s) in context.json have empty descriptions. Please add context before committing.`);
|
||||
}
|
||||
}
|
||||
|
||||
await fs.writeFile(CONTEXT_FILE, newContent);
|
||||
})();
|
||||
@@ -0,0 +1,21 @@
|
||||
const {SUPPORTED_LOCALES} = require('./');
|
||||
|
||||
/**
|
||||
* @type {import('i18next-parser').UserConfig}
|
||||
*/
|
||||
module.exports = {
|
||||
locales: SUPPORTED_LOCALES,
|
||||
|
||||
keySeparator: false,
|
||||
namespaceSeparator: false,
|
||||
|
||||
defaultNamespace: process.env.NAMESPACE || 'translation',
|
||||
|
||||
createOldCatalogs: false,
|
||||
indentation: 4,
|
||||
sort: true,
|
||||
|
||||
failOnUpdate: process.env.CI,
|
||||
|
||||
output: 'locales/$LOCALE/$NAMESPACE.json'
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
const i18n = require('./lib/i18n');
|
||||
|
||||
// Explicit exports for better bundler compatibility
|
||||
module.exports = i18n;
|
||||
module.exports.default = i18n;
|
||||
module.exports.LOCALE_DATA = i18n.LOCALE_DATA;
|
||||
module.exports.SUPPORTED_LOCALES = i18n.SUPPORTED_LOCALES;
|
||||
module.exports.generateResources = i18n.generateResources;
|
||||
@@ -0,0 +1,155 @@
|
||||
const i18next = require('i18next');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const debug = require('@tryghost/debug')('i18n');
|
||||
|
||||
// Locale data loaded from JSON (single source of truth)
|
||||
const LOCALE_DATA = require('./locale-data.json');
|
||||
|
||||
// Export just the locale codes for backward compatibility
|
||||
const SUPPORTED_LOCALES = LOCALE_DATA.map(locale => locale.code);
|
||||
|
||||
function generateResources(locales, ns) {
|
||||
return locales.reduce((acc, locale) => {
|
||||
let res;
|
||||
// add an extra fallback - this handles the case where we have a partial set of translations for some reason
|
||||
// by falling back to the english translations
|
||||
try {
|
||||
res = require(`../locales/${locale}/${ns}.json`);
|
||||
} catch (err) {
|
||||
res = require(`../locales/en/${ns}.json`);
|
||||
}
|
||||
|
||||
// Note: due some random thing in TypeScript, 'requiring' a JSON file with a space in a key name, only adds it to the default export
|
||||
// If changing this behaviour, please also check the comments and signup-form apps in another language (mainly sentences with a space in them)
|
||||
acc[locale] = {
|
||||
[ns]: {...res, ...(res.default && typeof res.default === 'object' ? res.default : {})}
|
||||
};
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
|
||||
function generateThemeResources(lng, themeLocalesPath) {
|
||||
if (!themeLocalesPath) {
|
||||
return {
|
||||
[lng]: {
|
||||
theme: {}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Get available theme locales by scanning the directory
|
||||
let availableLocales = [];
|
||||
try {
|
||||
const files = fs.readdirSync(themeLocalesPath);
|
||||
availableLocales = files
|
||||
.filter(file => file.endsWith('.json'))
|
||||
.map(file => file.replace('.json', ''));
|
||||
} catch (err) {
|
||||
// If we can't read the directory, fall back to just trying the requested locale and English
|
||||
|
||||
availableLocales = [lng, 'en'];
|
||||
}
|
||||
|
||||
// Always include the requested locale and English as fallbacks
|
||||
const locales = [...new Set([lng, ...availableLocales, 'en'])];
|
||||
|
||||
return locales.reduce((acc, locale) => {
|
||||
let res;
|
||||
let needsFallback = false;
|
||||
// Try to load the locale file, fallback to English
|
||||
const localePath = path.join(themeLocalesPath, `${locale}.json`);
|
||||
if (fs.existsSync(localePath)) {
|
||||
try {
|
||||
// Delete from require cache to ensure fresh reads for theme files
|
||||
delete require.cache[require.resolve(localePath)];
|
||||
res = require(localePath);
|
||||
} catch (err) {
|
||||
debug(`Error loading theme locale file: ${locale}`);
|
||||
needsFallback = true;
|
||||
}
|
||||
} else {
|
||||
needsFallback = true;
|
||||
}
|
||||
|
||||
if (needsFallback) {
|
||||
// Fallback to English if it's not the locale we're already trying
|
||||
if (locale !== 'en') {
|
||||
try {
|
||||
const enPath = path.join(themeLocalesPath, 'en.json');
|
||||
if (fs.existsSync(enPath)) {
|
||||
// Delete from require cache to ensure fresh reads for theme files
|
||||
delete require.cache[require.resolve(enPath)];
|
||||
res = require(enPath);
|
||||
} else {
|
||||
res = {};
|
||||
}
|
||||
} catch (enErr) {
|
||||
res = {};
|
||||
}
|
||||
} else {
|
||||
debug(`Theme en.json file not found`);
|
||||
res = {};
|
||||
}
|
||||
}
|
||||
|
||||
// Handle the same default export issue as other namespaces
|
||||
acc[locale] = {
|
||||
theme: {...res, ...(res.default && typeof res.default === 'object' ? res.default : {})}
|
||||
};
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} [lng]
|
||||
* @param {'ghost'|'portal'|'test'|'signup-form'|'comments'|'search'|'theme'} ns
|
||||
*/
|
||||
module.exports = (lng = 'en', ns = 'portal', options = {}) => {
|
||||
const i18nextInstance = i18next.createInstance();
|
||||
const interpolation = {
|
||||
prefix: '{',
|
||||
suffix: '}'
|
||||
};
|
||||
if (ns === 'theme') {
|
||||
interpolation.escapeValue = false;
|
||||
}
|
||||
let resources;
|
||||
if (ns !== 'theme') {
|
||||
resources = generateResources(SUPPORTED_LOCALES, ns);
|
||||
} else {
|
||||
debug(`generateThemeResources: ${lng}, ${options.themePath}`);
|
||||
resources = generateThemeResources(lng, options.themePath);
|
||||
}
|
||||
|
||||
i18nextInstance.init({
|
||||
lng,
|
||||
|
||||
// allow keys to be phrases having `:`, `.`
|
||||
nsSeparator: false,
|
||||
keySeparator: false,
|
||||
|
||||
// if the value is an empty string, return the key
|
||||
returnEmptyString: false,
|
||||
|
||||
// do not load a fallback
|
||||
fallbackLng: {
|
||||
no: ['nb', 'en'],
|
||||
default: ['en']
|
||||
},
|
||||
|
||||
ns: ns,
|
||||
defaultNS: ns,
|
||||
|
||||
// separators
|
||||
interpolation,
|
||||
|
||||
resources
|
||||
});
|
||||
|
||||
return i18nextInstance;
|
||||
};
|
||||
|
||||
module.exports.SUPPORTED_LOCALES = SUPPORTED_LOCALES;
|
||||
module.exports.LOCALE_DATA = LOCALE_DATA;
|
||||
module.exports.generateResources = generateResources;
|
||||
@@ -0,0 +1,64 @@
|
||||
[
|
||||
{"code": "af", "label": "Afrikaans"},
|
||||
{"code": "ar", "label": "Arabic"},
|
||||
{"code": "bg", "label": "Bulgarian"},
|
||||
{"code": "bn", "label": "Bengali"},
|
||||
{"code": "bs", "label": "Bosnian"},
|
||||
{"code": "ca", "label": "Catalan"},
|
||||
{"code": "cs", "label": "Czech"},
|
||||
{"code": "da", "label": "Danish"},
|
||||
{"code": "de", "label": "German"},
|
||||
{"code": "de-CH", "label": "Swiss German"},
|
||||
{"code": "el", "label": "Greek"},
|
||||
{"code": "en", "label": "English"},
|
||||
{"code": "eo", "label": "Esperanto"},
|
||||
{"code": "es", "label": "Spanish"},
|
||||
{"code": "et", "label": "Estonian"},
|
||||
{"code": "eu", "label": "Basque"},
|
||||
{"code": "fa", "label": "Persian/Farsi"},
|
||||
{"code": "fi", "label": "Finnish"},
|
||||
{"code": "fr", "label": "French"},
|
||||
{"code": "gd", "label": "Gaelic (Scottish)"},
|
||||
{"code": "he", "label": "Hebrew"},
|
||||
{"code": "hi", "label": "Hindi"},
|
||||
{"code": "hr", "label": "Croatian"},
|
||||
{"code": "hu", "label": "Hungarian"},
|
||||
{"code": "id", "label": "Indonesian"},
|
||||
{"code": "is", "label": "Icelandic"},
|
||||
{"code": "it", "label": "Italian"},
|
||||
{"code": "ja", "label": "Japanese"},
|
||||
{"code": "ko", "label": "Korean"},
|
||||
{"code": "kz", "label": "Kazakh"},
|
||||
{"code": "lt", "label": "Lithuanian"},
|
||||
{"code": "lv", "label": "Latvian"},
|
||||
{"code": "mk", "label": "Macedonian"},
|
||||
{"code": "mn", "label": "Mongolian"},
|
||||
{"code": "ms", "label": "Malay"},
|
||||
{"code": "nb", "label": "Norwegian Bokmål"},
|
||||
{"code": "ne", "label": "Nepali"},
|
||||
{"code": "nl", "label": "Dutch"},
|
||||
{"code": "nn", "label": "Norwegian Nynorsk"},
|
||||
{"code": "pa", "label": "Punjabi"},
|
||||
{"code": "pl", "label": "Polish"},
|
||||
{"code": "pt", "label": "Portuguese"},
|
||||
{"code": "pt-BR", "label": "Portuguese (Brazil)"},
|
||||
{"code": "ro", "label": "Romanian"},
|
||||
{"code": "ru", "label": "Russian"},
|
||||
{"code": "si", "label": "Sinhala"},
|
||||
{"code": "sk", "label": "Slovak"},
|
||||
{"code": "sl", "label": "Slovenian"},
|
||||
{"code": "sq", "label": "Albanian"},
|
||||
{"code": "sr", "label": "Serbian"},
|
||||
{"code": "sr-Cyrl", "label": "Serbian (Cyrillic)"},
|
||||
{"code": "sv", "label": "Swedish"},
|
||||
{"code": "sw", "label": "Swahili"},
|
||||
{"code": "ta", "label": "Tamil"},
|
||||
{"code": "th", "label": "Thai"},
|
||||
{"code": "tr", "label": "Turkish"},
|
||||
{"code": "uk", "label": "Ukrainian"},
|
||||
{"code": "ur", "label": "Urdu"},
|
||||
{"code": "uz", "label": "Uzbek"},
|
||||
{"code": "vi", "label": "Vietnamese"},
|
||||
{"code": "zh", "label": "Chinese"},
|
||||
{"code": "zh-Hant", "label": "Traditional Chinese"}
|
||||
]
|
||||
@@ -0,0 +1,424 @@
|
||||
{
|
||||
"(save {highestYearlyDiscount}%)": "Appears in portal next to the yearly plan selector",
|
||||
"+1 (123) 456-7890": "Placeholder for phone number input field",
|
||||
"1 comment": "Comment count displayed above the comments section in case there is only one",
|
||||
"1 month": "Duration label for a 1 month subscription",
|
||||
"1 month free": "Retention offer discount badge shown during subscription cancellation",
|
||||
"1 year": "Duration label for a 1 year subscription",
|
||||
"Access your RSS feeds": "Default description text for the Transistor podcast integration in Portal",
|
||||
"Account": "A label in Portal for your account area",
|
||||
"Account details updated successfully": "Popover message in Portal",
|
||||
"Account settings": "A label in Portal for your account settings",
|
||||
"Add a personal note": "Becomes the field label for donations in Stripe",
|
||||
"Add comment": "Button text to post a comment",
|
||||
"Add context to your comment, share your name and expertise to foster a healthy discussion.": "Invitation to include additional info when commenting",
|
||||
"Add reply": "Button text to post your reply",
|
||||
"Add your expertise": "A link in the comments to add ones expertise if not yet provided",
|
||||
"After a free trial ends, you will be charged the regular price for the tier you've chosen. You can always cancel before then.": "Confirmation message explaining how free trials work during signup",
|
||||
"All the best!": "A light-hearted ending to an email",
|
||||
"Already a member?": "A link displayed on signup screen, inviting people to log in if they have already signed up previously",
|
||||
"An error occurred": "An error message in Portal",
|
||||
"An unexpected error occured. Please try again or <a>contact support</a> if the error persists.": "Notification if an unexpected error occurs.",
|
||||
"Anonymous": "Comment placed by a member without a name",
|
||||
"Are you sure?": "Asks the user to confirm deleting a comment",
|
||||
"Authors": "Label for search results",
|
||||
"Back": "A button to return to the previous page",
|
||||
"Back to Log in": "A button to return to the login screen",
|
||||
"Become a member of {publication} to start commenting.": "Call to action in the comments app",
|
||||
"Become a paid member of {publication} to start commenting.": "Call to action in the comments app",
|
||||
"Become a paid member of {site} to get access to all premium content.": "Call to action for paid content",
|
||||
"Before you go": "Page heading in Portal shown when presenting a retention offer during subscription cancellation",
|
||||
"Best": "Text appears in the sorting selector for comments",
|
||||
"Billing info & receipts": "Title in Portal for the section which links to Stripe's billing portal",
|
||||
"Black Friday": "An example offer name",
|
||||
"Bluesky": "Share option in Portal share modal overflow menu",
|
||||
"By {authors}": "Newsletter header text",
|
||||
"Cancel": "Button text",
|
||||
"Cancel anytime.": "A label explaining that the trial can be cancelled at any time",
|
||||
"Cancel subscription": "A button to cancel a paid subscription",
|
||||
"Canceled": "Badge label shown on Portal account page when a subscription has been canceled",
|
||||
"Cancellation reason": "A textarea inviting members who are publishing to share feedback with the publisher",
|
||||
"Change": "A button to change the current plan",
|
||||
"Change plan": "Header for the change plan screen in Portal",
|
||||
"Check spam & promotions folders": "A section title in email receiving FAQ",
|
||||
"Check with your mail provider": "A section title in email receiving FAQ",
|
||||
"Check your inbox to verify email update": "Displayed when members change email addresses in Portal",
|
||||
"Choose": "A button to select a plan",
|
||||
"Choose a different plan": "A button for members to switch between plans",
|
||||
"Choose a plan": "Header for the plan selection screen in Portal",
|
||||
"Choose your newsletters": "A title for a screen where members can choose which email newsletters to receive",
|
||||
"Click here to retry": "A link to retry the login process",
|
||||
"Close": "A button to close or dismiss portal and other UI components",
|
||||
"Code": "Aria-label for the one-time code input field on the verification page in Portal",
|
||||
"Comment": "Button text to post your comment, on smaller devices",
|
||||
"Comment preferences updated.": "Shown when a user changes preferences in Portal",
|
||||
"Commenting disabled": "Heading shown in the comments UI when a member's commenting privileges have been disabled",
|
||||
"Comments": "A title for the comments section on a post",
|
||||
"Complete signup for {siteTitle}!": "Magic link email",
|
||||
"Complete your profile": "Title of the modal to edit your expertise in the comments app",
|
||||
"Complete your sign up to {siteTitle}!": "Magic link email",
|
||||
"Complimentary": "Label of a paid plan which has been added to a member's account for free",
|
||||
"Confirm": "A button to confirm",
|
||||
"Confirm cancellation": "A button to confirm the cancellation of a subscription",
|
||||
"Confirm email address": "Title for a modal for confirming. As the modal calls for action, the imperative form shall be used",
|
||||
"Confirm signup": "A link to confirm the email in signup",
|
||||
"Confirm subscription": "Button to confirm the change to a new plan",
|
||||
"Confirm your email address": "Subject of the confirmation email sent to members",
|
||||
"Confirm your email update for {siteTitle}!": "Sent to a member when they change their email address",
|
||||
"Confirm your subscription to {siteTitle}": "Magic link email",
|
||||
"Contact support": "Button to send an email to support",
|
||||
"Continue": "Continue button",
|
||||
"Continue subscription": "Button to continue a (cancelled) paid subscription",
|
||||
"Copied": "Replaces 'Copy link' button label in Portal share modal after URL is copied",
|
||||
"Copy link": "Button in Portal share modal to copy the post URL to clipboard",
|
||||
"Could not create Stripe billing portal session": "Error message in Portal's data-attributes handler when the Stripe billing portal fails to open via a theme [data-members-manage-billing] element",
|
||||
"Could not create Stripe checkout session": "An error message indicating a problem with Stripe",
|
||||
"Could not sign in. Login link expired.": "Message when a login link has expired",
|
||||
"Could not update email! Invalid link.": "Message when an email update link is invalid",
|
||||
"Create a new contact": "A section title in email receiving FAQ",
|
||||
"Current plan": "Label for the current plan",
|
||||
"Delete": "Delete button",
|
||||
"Delete account": "A button for members to delete their account",
|
||||
"Deleted": "Shown when a member deletes a comment",
|
||||
"Deleted member": "Name of a member used for comments when the member has been deleted",
|
||||
"Deleting": "Shown while a comment deletion is in progress",
|
||||
"Device:": "Label for device verification (forthcoming)",
|
||||
"Didn't mean to do this? Manage your preferences <button>here</button>.": "Message shown after unsubscribing from a newsletter",
|
||||
"Discussion": "Short default title for the comments section used on smaller devices",
|
||||
"Don't have an account?": "A link on the login screen, directing people who do not yet have an account to sign up.",
|
||||
"Edit": "A button to edit the user profile",
|
||||
"Edit this comment": "Context menu action to edit a comment",
|
||||
"Email": "A label for email address input",
|
||||
"Email newsletter": "Title for the email newsletter settings",
|
||||
"Email newsletter settings updated": "A message showing newsletter settings have been updated",
|
||||
"Email preferences": "A label for email settings",
|
||||
"Email preferences updated.": "Shown when a member updates email preferences",
|
||||
"Email sent": "Button text after being clicked, when an email has been sent to confirm a new subscription. Should be short.",
|
||||
"Emails": "A label for a list of emails",
|
||||
"Emails disabled": "Title for a message in portal telling members that they are not receiving emails, due to repeated delivery failures to their address",
|
||||
"Ends {offerEndDate}": "Portal - label for an offer that ends on a specific date",
|
||||
"Enjoy a free month on us.": "Retention offer message in Portal shown when offering one free month to a member cancelling their subscription",
|
||||
"Enjoy a free month on us. You won't be charged until {newBillingDate}.": "Retention offer message in Portal shown when offering one free month, with the next billing date",
|
||||
"Enjoy {amountOff} off forever.": "Retention offer message in Portal for a permanent discount offer during subscription cancellation",
|
||||
"Enjoy {months} free months on us.": "Retention offer message in Portal for multi-month free offers during subscription cancellation",
|
||||
"Enjoy {months} free months on us. You won't be charged until {newBillingDate}.": "Retention offer message in Portal for multi-month free offers, with the next billing date",
|
||||
"Enter code above": "Portal - error message when OTC code isn't filled in",
|
||||
"Enter your email address": "Error message when an email address is missing",
|
||||
"Enter your name": "Placeholder input when editing your name in the comments app in the expertise dialog",
|
||||
"Error": "Status indicator for a notification",
|
||||
"Expertise": "Input label when editing your expertise in the comments app",
|
||||
"Expires {expiryDate}": "Label for when a complimentary subscription expires (in portal)",
|
||||
"Facebook": "Share option in Portal share modal overflow menu",
|
||||
"Failed to cancel subscription, please try again": "error message",
|
||||
"Failed to log in, please try again": "error message",
|
||||
"Failed to log out, please try again": "error message",
|
||||
"Failed to open billing portal, please try again": "Error notification in Portal when the Stripe billing portal cannot be opened",
|
||||
"Failed to process checkout, please try again": "error message",
|
||||
"Failed to send magic link email": "error message",
|
||||
"Failed to send verification email": "error message",
|
||||
"Failed to sign up, please try again": "error message",
|
||||
"Failed to update account data": "error message",
|
||||
"Failed to update account details": "error message",
|
||||
"Failed to update billing information, please try again": "Error notification in Portal when updating payment method or billing address fails",
|
||||
"Failed to update newsletter settings": "error message",
|
||||
"Failed to update subscription, please try again": "error message",
|
||||
"Failed to verify code, please try again": "Error message when one-time code verification fails during sign-in",
|
||||
"For security verification, enter the code below to sign in to {siteTitle}:": "Device verification email",
|
||||
"For your security, the link will expire in 24 hours time.": "Descriptive text in emails about authentication links",
|
||||
"Forever": "Label for a discounted price which has no expiry date",
|
||||
"Founder @ Acme Inc": "Placeholder value for the input box when editing your expertise",
|
||||
"Free Trial – Ends {trialEnd}": "Portal - label for a free trial that ends on a specific date",
|
||||
"Full-time parent": "Example of an expertise of a person used in comments when editing your expertise",
|
||||
"Get help": "A link to contact support",
|
||||
"Get in touch for help": "A section title in email receiving FAQ",
|
||||
"Get notified when someone replies to your comment": "A label for a setting allowing email notifications to be received",
|
||||
"Gift subscription": "Subscription label for members who have an active gift subscription",
|
||||
"Give feedback on this post": "A label that goes with the member feedback buttons at the bottom of newsletters",
|
||||
"Head of Marketing at Acme, Inc": "Example of an expertise of a person used in comments when editing your expertise",
|
||||
"Help! I'm not receiving emails": "A section title in email receiving FAQ",
|
||||
"Here are a few other sites you may enjoy.": "Text introducing related sites someone may find interesting",
|
||||
"Here's your code to login to {siteTitle}": "Device verification email",
|
||||
"Hey there!": "An introduction/opening to an email",
|
||||
"Hey there,": "An introduction/opening to an email",
|
||||
"Hidden for members": "Shown to moderators when a comment is hidden from members",
|
||||
"Hide": "Action in the context menu for administrators, on smaller devices",
|
||||
"Hide comment": "Action in the context menu for administrators",
|
||||
"If a newsletter is flagged as spam, emails are automatically disabled for that address to make sure you no longer receive any unwanted messages.": "Paragraph in the email suppression FAQ",
|
||||
"If the spam complaint was accidental, or you would like to begin receiving emails again, you can resubscribe to emails by clicking the button on the previous screen.": "Paragraph in the email suppression FAQ",
|
||||
"If you cancel your subscription now, you will continue to have access until {periodEnd}.": "Portal - shown to a member who is cancelling their subscription.",
|
||||
"If you did not make this request, you can safely ignore this email.": "Footer text in signup/login emails",
|
||||
"If you did not make this request, you can simply delete this message.": "Footer text in signup/login emails",
|
||||
"If you didn't try to sign in recently, you can safely ignore this email to deny access.": "Footer text in signup/login emails",
|
||||
"If you have a corporate or government email account, reach out to your IT department and ask them to allow emails to be received from {senderEmail}": "Footer text in signup/login emails",
|
||||
"If you have an account, a login link has been sent to your inbox. If it doesn't arrive in 3 minutes, be sure to check your spam folder.": "A message displayed during signin process.",
|
||||
"If you have an account, an email has been sent to {submittedEmailOrInbox}. Click the link inside or enter your code below.": "A message displayed after requesting a magic link or OTC code.",
|
||||
"If you would like to start receiving emails again, the best next steps are to check your email address on file for any issues and then click resubscribe on the previous screen.": "Paragraph in the email suppression FAQ",
|
||||
"If you're not receiving the email newsletter you've subscribed to, here are a few things to check.": "Paragraph in the email receiving FAQ",
|
||||
"If you've completed all these checks and you're still not receiving emails, you can reach out to get support by contacting {supportAddress}.": "Email receiving FAQ",
|
||||
"In the event a permanent failure is received when attempting to send a newsletter, emails will be disabled on the account.": "Paragraph in the email suppression FAQ",
|
||||
"In your email client add {senderEmail} to your contacts list. This signals to your mail provider that emails sent from this address should be trusted.": "Email receiving FAQ",
|
||||
"Invalid email address": "Error message when a provided email address is invalid",
|
||||
"Invalid verification code": "Error message in Portal when the entered one-time sign-in code is incorrect or expired",
|
||||
"Jamie Larson": "An unisex name of a person we use in examples",
|
||||
"Join the discussion": "Placeholder value of the comments input box",
|
||||
"Just now": "Time indication when a comment has been posted 'just now'",
|
||||
"Keep reading": "Header above the selection of recent posts, shown in the newsletter email",
|
||||
"Less like this": "A label for the thumbs-down response in member feedback at the bottom of emails",
|
||||
"LinkedIn": "Share button in Portal share modal",
|
||||
"Load more ({amount})": "Button text to load more replies in the comments app",
|
||||
"Local resident": "Example of an expertise of a person used in comments when editing your expertise",
|
||||
"Make sure emails aren't accidentally ending up in the Spam or Promotions folders of your inbox. If they are, click on \"Mark as not spam\" and/or \"Move to inbox\".": "Paragraph in the email receiving FAQ",
|
||||
"Manage": "A button for managing settings",
|
||||
"Manage subscription": "Header in Portal for the subscription management screen",
|
||||
"Manage your preferences": "Link text in the member welcome email footer for managing communication preferences",
|
||||
"Maybe later": "An informal phrase to dismiss or close a popup",
|
||||
"Member discussion": "Default title for the comments section on a post",
|
||||
"Member since": "Shown in the 'subscription status' section of the newsletter, will be followed by a date",
|
||||
"Memberships from this email domain are currently restricted.": "Shown when someone tries to sign up with a blocked domain",
|
||||
"Memberships unavailable, contact the owner for access.": "Inform a user that memberships are only available by contacting the site owner",
|
||||
"Monthly": "A label to indicate a monthly payment cadence",
|
||||
"More like this": "A label for the thumbs-up response in member feedback at the bottom of emails",
|
||||
"More options": "Button in Portal share modal that opens a menu with additional share options",
|
||||
"Name": "A label to indicate a member's name",
|
||||
"Need more help? Contact support": "A link to contact support",
|
||||
"Neurosurgeon": "Example of an expertise of a person used in comments when editing your expertise",
|
||||
"New comment on {postTitle}": "Title in email notifying author about new comment to hist post",
|
||||
"New reply to your comment on {siteTitle}": "Email subject when notifying user about reply to his comment",
|
||||
"Newest": "Text appears in the sorting selector for comments",
|
||||
"Newsletters can be disabled on your account for two reasons: A previous email was marked as spam, or attempting to send an email resulted in a permanent failure (bounce).": "A paragraph in the email suppression FAQ",
|
||||
"No matches found": "Shown in search when 0 results",
|
||||
"No member exists with this e-mail address.": "Shown when trying to sign in.",
|
||||
"No thanks, I want to cancel": "Button in Portal to decline a retention offer and proceed with subscription cancellation",
|
||||
"Not receiving emails?": "A link in portal to take members to an FAQ area about what to do if you're not receiving emails",
|
||||
"Now check your email!": "A confirmation message after logging in or signing up",
|
||||
"Oldest": "Text appears in the sorting selector for comments",
|
||||
"Once deleted, this comment can’t be recovered.": "Warning message before deleting a comment",
|
||||
"Once resubscribed, if you still don't see emails in your inbox, check your spam folder. Some inbox providers keep a record of previous spam complaints and will continue to flag emails. If this happens, mark the latest newsletter as 'Not spam' to move it back to your primary inbox.": "A paragraph in the email suppression FAQ",
|
||||
"One hour ago": "Time a comment was placed",
|
||||
"One min ago": "Time since a comment was made",
|
||||
"Open AOL Mail": "Shown on signup and signin if your email is detected as AOL Mail",
|
||||
"Open Feedbin": "Shown on signup and signin if your email is detected as Feedbin",
|
||||
"Open Gmail": "Shown on signup and signin if your email is detected as Gmail",
|
||||
"Open Hey": "Shown on signup and signin if your email is detected as Hey",
|
||||
"Open Mail.ru": "Shown on signup and signin if your email is detected as Mail.ru",
|
||||
"Open Outlook": "Shown on signup and signin if your email is detected as Outlook",
|
||||
"Open Proton Mail": "Shown on signup and signin if your email is detected as Proton Mail",
|
||||
"Open Yahoo Mail": "Shown on signup and signin if your email is detected as Yahoo Mail",
|
||||
"Open email": "Shown on signup and signin if your email is detected, but we don't know which provider",
|
||||
"Open iCloud Mail": "Shown on signup and signin if your email is detected as iCloud Mail",
|
||||
"Or use this link to securely sign in": "Text in the sign-in email above the magic link fallback, shown when a one-time code is provided",
|
||||
"Or, skip the code and sign in directly": "Text in the sign-in email offering a direct magic link as an alternative to entering the one-time code",
|
||||
"Permanent failure (bounce)": "A section title in the email suppression FAQ",
|
||||
"Phone number": "Label for phone number input",
|
||||
"Plan": "Label for the default subscription plan",
|
||||
"Plan checkout was cancelled.": "Notification for when a plan checkout was cancelled",
|
||||
"Plan upgrade was cancelled.": "Notification for when a plan upgrade was cancelled",
|
||||
"Please confirm your email address with this link:": "Descriptive text in signup emails, right before the button members click to confirm their address",
|
||||
"Please contact {supportAddress} to adjust your complimentary subscription.": "Directions in portal",
|
||||
"Please enter a valid email address": "Err message when an email address is invalid",
|
||||
"Please enter {fieldName}": "error message when a required field is missing",
|
||||
"Please fill in required fields": "Error message when a required field is missing",
|
||||
"Podcasts": "Default heading for the Transistor podcast integration section in Portal",
|
||||
"Posts": "Search result header",
|
||||
"Price": "A label to indicate price of a tier",
|
||||
"Re-enable emails": "A button for members to turn-back-on emails, if they have been previously disabled as a result of delivery failures",
|
||||
"Recommendations": "A suggestion by/for another site of interest to users",
|
||||
"Renews at {price}.": "Portal - label for a subscription that renews at a specific price",
|
||||
"Replied to": "Comments label",
|
||||
"Reply": "Button to reply to a comment",
|
||||
"Reply to": "Shows what comment is being replied to. (Followed by text of the comment being replied to)",
|
||||
"Reply to comment": "Placeholder value of the input box when placing a reply to a comment",
|
||||
"Report": "Button to report a comment",
|
||||
"Report comment": "Used in the context menu",
|
||||
"Report this comment?": "Title of the modal to report a comment",
|
||||
"Resume subscription": "Button in the canceled subscription banner on the Portal account page to resume a canceled subscription",
|
||||
"Retry": "When something goes wrong, this link allows people to re-attempt the same action",
|
||||
"Save": "A button to save",
|
||||
"Save {amountOff} on your next billing cycle. Then {currency}{originalPrice}/{cadence}.": "Retention offer message in Portal for a single-cycle discount during subscription cancellation",
|
||||
"Save {amountOff} on your next {durationInMonths} billing cycles. Then {currency}{originalPrice}/{cadence}.": "Retention offer message in Portal for a multi-cycle discount during subscription cancellation",
|
||||
"Search posts, tags and authors": "Search placeholder text",
|
||||
"Secure sign in link for {siteTitle}": "Magic link email",
|
||||
"See you soon!": "A sign-off/ending to a signup email",
|
||||
"Send an email and say hi!": "A section title in email receiving FAQ",
|
||||
"Send an email to {senderEmail} and say hello. This can also help signal to your mail provider that emails to and from this address should be trusted.": "Email receiving FAQ",
|
||||
"Sending": "Button text when reporting a comment",
|
||||
"Sending login link...": "A loading status message when a member has just clicked to login",
|
||||
"Sending...": "A loading status message when an email is being sent",
|
||||
"Sent": "Button text when a comment was reported succesfully",
|
||||
"Sent to {email}": "Magic link email footer",
|
||||
"Share": "Title of Portal share modal for sharing a post, and label for the newsletter email footer Share button text passed as buttonText to the feedbackButton partial",
|
||||
"Show": "Context menu action for a comment, for administrators - smaller devices",
|
||||
"Show 1 more reply": "Button text to load the last remaining reply for a comment",
|
||||
"Show all": "Show all recommendations",
|
||||
"Show comment": "Context menu action for a comment, for administrators",
|
||||
"Show more results": "Link shown if there are additional search results available",
|
||||
"Show {amount} more replies": "Button text to load more replies in the comments app",
|
||||
"Sign in": "A button to sign in",
|
||||
"Sign in now": "Button text in the sign-in email when a one-time code is provided",
|
||||
"Sign in to {siteTitle}": "Magic link email",
|
||||
"Sign in to {siteTitle} with code {otc}": "Subject line for the sign-in email when a one-time code is sent",
|
||||
"Sign in verification": "Magic link email",
|
||||
"Sign out": "A button to sign out",
|
||||
"Sign up": "A button to sign up",
|
||||
"Sign up now": "Button text to sign up in order to post a comment",
|
||||
"Signup error: Invalid link": "Notification text when an invalid / expired signup link is used",
|
||||
"Signups from this email domain are currently restricted.": "error message",
|
||||
"Someone just replied to your comment": "Email text when informing user about reply to his comment",
|
||||
"Someone just replied to your comment on {postTitle}.": "Email text when informing user about reply to his comment on post",
|
||||
"Something went wrong, please try again later.": "error message",
|
||||
"Something went wrong, please try again.": "Error message when subscribing to a newsletter fails in the signup form embed",
|
||||
"Sorry, no recommendations are available right now.": "Shown if a user visits the recommendations screen in portal when recommendations aren't available",
|
||||
"Sorry, that didn’t work.": "Title of a page when an error occured while submitting feedback",
|
||||
"Sort by": "Used for sorting comments, appears next to a dropdown with options (oldest, newest, best)",
|
||||
"Spam complaints": "A title in the email suppression FAQ",
|
||||
"Start the conversation": "Title of the CTA section of comments when not signed in and when there are no comments yet",
|
||||
"Start {amount}-day free trial": "Portal - label for a free trial that lasts a specific number of days",
|
||||
"Starting today": "Message when a subscription starts today",
|
||||
"Starting {startDate}": "Portal - label for a subscription that starts on a specific date (after trial)",
|
||||
"Submit feedback": "A button for submitting member feedback",
|
||||
"Subscribe": "Title of a section for subscribing to a newsletter",
|
||||
"Subscribed": "Status of a newsletter which a member has subscribed to",
|
||||
"Subscription details": "Label at the top of the newsletter section",
|
||||
"Subscription plan updated successfully": "Popover message in Portal",
|
||||
"Success": "Status indicator for a notification",
|
||||
"Success! Check your email for magic link to sign-in.": "Notification text when the user has been sent a magic link to sign-in",
|
||||
"Success! Your account is fully activated, you now have access to all content.": "Notification text when a user has activated their email and can access content",
|
||||
"Success! Your email is updated.": "Notification text when a user has updated their email address",
|
||||
"Successfully unsubscribed": "A confirmation message when a member clicks an unsubscribe link",
|
||||
"Tags": "Search results header",
|
||||
"Tap the link below to complete the signup process for {siteTitle}, and be automatically signed in:": "Magic link email",
|
||||
"Thank you for signing up to {siteTitle}!": "Magic link email",
|
||||
"Thank you for subscribing to {siteTitle}!": "Magic link email",
|
||||
"Thank you for subscribing to {siteTitle}.": "Magic link email",
|
||||
"Thank you for subscribing to {siteTitle}. Tap the link below to be automatically signed in:": "Magic link email",
|
||||
"Thank you for subscribing. Before you start reading, below are a few other sites you may enjoy.": "Subscription cofirmation message with notice of recommended sites",
|
||||
"Thank you for your support": "A success message, shown in a modal after a non-member has made a tip or donation",
|
||||
"Thank you for your support!": "A success message, shown in a notification after a member has made a tip or donation",
|
||||
"Thanks for the feedback!": "A confirmation message after submitting member feedback",
|
||||
"That didn't go to plan": "An error message indicating something went wrong",
|
||||
"The email address we have for you is {memberEmail} — if that's not correct, you can update it in your <button>account settings area</button>.": "Email receiving FAQ",
|
||||
"The linked comment is no longer available.": "A notice shown when a permalink targets a comment that has been hidden or deleted",
|
||||
"There was a problem submitting your feedback. Please try again a little later.": "An error message for when submitting feedback has failed",
|
||||
"There was an error cancelling your subscription, please try again.": "error message",
|
||||
"There was an error continuing your subscription, please try again.": "error message",
|
||||
"There was an error processing your payment. Please try again.": "error message",
|
||||
"There was an error sending the email, please try again": "error message",
|
||||
"This comment has been hidden.": "Text for a comment thas was hidden",
|
||||
"This comment has been removed.": "Text for a comment thas was removed",
|
||||
"This email address will not be used.": "This is in the footer of signup verification emails, and comes right after 'If you did not make this request, you can simply delete this message.'",
|
||||
"This message was sent from {siteDomain} to {email}.": "Text in the footer of emails, e.g. reply to comments notifications",
|
||||
"This site is invite-only, contact the owner for access.": "A message on the member login screen indicating that a site is not-open to public signups",
|
||||
"This site is not accepting donations at the moment.": "Error message in Portal when a user visits the donations page but donations are disabled",
|
||||
"This site is not accepting payments at the moment.": "An error message shown when a tips or donations link is opened but the site has donations disabled",
|
||||
"This site only accepts paid members.": "error message for sign-up",
|
||||
"Threads": "Share option in Portal share modal overflow menu",
|
||||
"To complete signup, click the confirmation link in your inbox. If it doesn't arrive within 3 minutes, check your spam folder!": "A confirmation message displayed during the signup process, indicating that the person signing up needs to go and check their email - and reminding them to check their spam folder, too",
|
||||
"To continue to stay up to date, subscribe to {publication} below.": "In the portal support section",
|
||||
"Too many attempts try again in {number} days.": "Error message when a user has tried to sign in too many times",
|
||||
"Too many attempts try again in {number} hours.": "Error message when a user has tried to sign in too many times",
|
||||
"Too many attempts try again in {number} minutes.": "Error message when a user has tried to sign in too many times",
|
||||
"Too many different sign-in attempts, try again in {number} days": "Error message when a user has tried to sign in too many times",
|
||||
"Too many different sign-in attempts, try again in {number} hours": "Error message when a user has tried to sign in too many times",
|
||||
"Too many different sign-in attempts, try again in {number} minutes": "Error message when a user has tried to sign in too many times",
|
||||
"Too many sign-up attempts, try again later": "error message",
|
||||
"Try free for {amount} days, then {originalPrice}.": "Portal - label for a free trial that lasts a specific number of days, followed by the original price",
|
||||
"Unable to initiate checkout session": "error message",
|
||||
"Unlock access to all newsletters by becoming a paid subscriber.": "A message to encourage members to upgrade to a paid subscription",
|
||||
"Unsubscribe": "Link in the newsletter",
|
||||
"Unsubscribe from all emails": "A button on the unsubscribe page, offering a shortcut to unsubscribe from every newsletter at the same time",
|
||||
"Unsubscribe from comment reply notifications": "Link text in comments reply email to unsubscribe from notifications",
|
||||
"Unsubscribed": "Status of a newsletter which a user has not subscribed to",
|
||||
"Unsubscribed from all emails.": "Shown if a member unsubscribes from all emails",
|
||||
"Unsubscribing from emails will not cancel your paid subscription to {title}": "Portal - newsletter subscription management",
|
||||
"Update": "A button to update the billing information",
|
||||
"Update your preferences": "A button for updating member preferences in their account area",
|
||||
"Upgrade": "An action button in the newsletter call-to-action",
|
||||
"Upgrade now": "Button text in the comments section, when you need to be a paid member in order to post a comment",
|
||||
"Upgrade to continue reading.": "Appears in the call to action",
|
||||
"Verification link sent, check your inbox": "Instruction to check email for recommendation verification",
|
||||
"Verify your email address is correct": "A section title in the email receiving FAQ",
|
||||
"Verifying...": "Button text in Portal shown while a one-time sign-in code is being verified",
|
||||
"View comments": "Link text in comment email to view the comments",
|
||||
"View in admin": "Context menu item in the comments UI that opens a comment in the Ghost admin panel",
|
||||
"View in browser": "Link in the newsletter header taking the user to the website",
|
||||
"View plans": "A button to view available plans",
|
||||
"We couldn't unsubscribe you as the email address was not found. Please contact the site owner.": "An error message when an unsubscribe link is clicked, but we don't have any record of the address being unsubscribed. Probably because the email address on the account has been changed.",
|
||||
"We'd hate to see you leave. How about a special offer to stay?": "Default description in Portal retention offer section during subscription cancellation",
|
||||
"Welcome back to {siteTitle}!": "Popup when a member logs in",
|
||||
"Welcome back to {siteTitle}! Your verification code is {otc}.": "Email preheader text for the sign-in email when a one-time code is sent",
|
||||
"Welcome back!": "A message when a user has logged in successfully",
|
||||
"Welcome back! Here's your code to sign in to {siteTitle}": "Paragraph in the sign-in email introducing the one-time code",
|
||||
"Welcome back! Use this link to securely sign in to your {siteTitle} account:": "Magic link email",
|
||||
"Welcome back, {name}!": "Popup when a member logs in",
|
||||
"Welcome to {siteTitle}": "Shown in recommendations section of Portal",
|
||||
"When an inbox fails to accept an email it is commonly called a bounce. In many cases, this can be temporary. However, in some cases, a bounced email can be returned as a permanent failure when an email address is invalid or non-existent.": "A paragraph from the email suppression FAQ",
|
||||
"When:": "Device verification (forthcoming)",
|
||||
"Where:": "Device verification (forthcoming)",
|
||||
"Why has my email been disabled?": "A section title from the email suppression FAQ",
|
||||
"X (Twitter)": "Share button in Portal share modal",
|
||||
"Yearly": "A label indicating an annual payment cadence",
|
||||
"Yesterday": "Time a comment was placed",
|
||||
"You are receiving this because you are a <strong>%%{status}%% subscriber</strong> to {site}.": "Shown at the bottom of the newsletter. Status will be one of free/paid/trialing/complimentary - see those strings below.",
|
||||
"You can also copy & paste this URL into your browser:": "Descriptive text displayed underneath the buttons in login/signup emails, right before authentication URLs which can be copy/pasted",
|
||||
"You can unsubscribe from these notifications at {profileUrl}.": "Footer text in comments email to manage notification preferences for users",
|
||||
"You can't post comments in this publication.": "Shown in the comments UI when a member's commenting privileges have been disabled and no support email is configured",
|
||||
"You can't post comments in this publication. <a>Contact support</a> for more information.": "Shown in the comments UI when a member's commenting privileges have been disabled. The <a>...</a> tag wraps a mailto link to the publication's support email address",
|
||||
"You currently have a free membership, upgrade to a paid subscription for full access.": "A message indicating that the member is using a free subcription, and could access more content with a paid subscription",
|
||||
"You have been successfully resubscribed": "A confirmation message when a member has had emails turned off, but they have been successfully turned back on",
|
||||
"You just tried to access your account from a new device.": "Device verification (forthcoming)",
|
||||
"You will not be signed up, and no account will be created for you.": "Descriptive text in signup emails indicating that if someone does NOT click on the confirmation button, then they will not be signed up to anything. This text is intended to reassure people who receive a signup confirmation email that they did not ask for.",
|
||||
"You will not be subscribed.": "Descriptive text in signup emails indicating that if someone does NOT click on the confirmation button, then they will not be signed up to anything. This text is intended to reassure people who receive a signup confirmation email that they did not ask for.",
|
||||
"You're currently not receiving emails": "Message for user who have newsletters disabled",
|
||||
"You're not receiving emails": "Shorter message for user who have newsletters disabled, for mobile devices",
|
||||
"You're not receiving emails because you either marked a recent message as spam, or because messages could not be delivered to your provided email address.": "An error message displayed in the member account area when newsletter delivery to their address has repeatedly failed or they have marked an email as spam.",
|
||||
"You're one tap away from subscribing to {siteTitle} — please confirm your email address with this link:": "Signup confirmation email",
|
||||
"You're one tap away from subscribing to {siteTitle}!": "Signup confirmation email",
|
||||
"You've successfully signed in.": "A notification displayed when the user signs in",
|
||||
"You've successfully subscribed to <strong>{siteTitle}</strong>": "Notification displayed after a successful signup. The <strong>...</strong> tag wraps the site name. {siteTitle} is the name of the publication",
|
||||
"Your account": "A label indicating member account details",
|
||||
"Your email": "Form label for the email address input in e.g. signup forms",
|
||||
"Your email address": "Placeholder text in an input field",
|
||||
"Your email has failed to resubscribe, please try again": "error message in portal",
|
||||
"Your free trial ends on {date}, at which time you will be charged the regular price. You can always cancel before then.": "Newsletter text - shown to trialing members when receiving the newsletter (if subscription status is being shown)",
|
||||
"Your input helps shape what gets published.": "Descriptive text displayed on the member feedback UI, telling people how their feedback is used",
|
||||
"Your name": "Form label for the name input in e.g. signup forms",
|
||||
"Your request will be sent to the owner of this site.": "Descriptive text displayed in the report comment modal",
|
||||
"Your subscription has been canceled and will expire on {date}. You can resume your subscription via your account settings.": "Shown in the newsletter footer when a member has cancelled their subscription but it hasn't expired yet.",
|
||||
"Your subscription has been canceled and will expire on {expiryDate}.": "Banner message shown on Portal account page when a subscription is canceled but still active until the period end",
|
||||
"Your subscription has expired.": "Subscription status, included at the bottom of newsletters",
|
||||
"Your subscription will expire on {date}.": "Appears in the newsletter footer, be sure to include {date} in the translation.",
|
||||
"Your subscription will expire on {expiryDate}": "Portal account page",
|
||||
"Your subscription will renew on {date}.": "Appears in the newsletter footer, be sure to include {date} in the translation.",
|
||||
"Your subscription will renew on {renewalDate}": "Portal account page",
|
||||
"Your subscription will start on {subscriptionStart}": "Portal account page",
|
||||
"Your verification code for {siteTitle}": "Device verification email",
|
||||
"complimentary": "Will be substituted into status for 'You are receiving this because you are a <strong>%%{status}%% subscriber</strong> to {site}.'",
|
||||
"edited": "label for an edited comment",
|
||||
"free": "Will be substituted into status for 'You are receiving this because you are a <strong>%%{status}%% subscriber</strong> to {site}.'",
|
||||
"jamie@example.com": "Placeholder for email input field",
|
||||
"month": "the subscription interval (monthly), following the /",
|
||||
"paid": "Will be substituted into status for 'You are receiving this because you are a <strong>%%{status}%% subscriber</strong> to {site}.'",
|
||||
"removed": "label for removed comment",
|
||||
"trialing": "Will be substituted into status for 'You are receiving this because you are a <strong>%%{status}%% subscriber</strong> to {site}.'",
|
||||
"year": "the subscription interval (monthly), following the /",
|
||||
"your inbox": "Fallback text substituted into 'submittedEmailOrInbox' in Portal sign-in confirmation when no email address is available",
|
||||
"{amount} characters left": "shown in comments editor",
|
||||
"{amount} comments": "shown in the comments app",
|
||||
"{amount} days free": "Portal - label for a free trial that lasts a specific number of days",
|
||||
"{amount} hrs ago": "Time since this comment was placed",
|
||||
"{amount} mins ago": "Time since this comment was placed",
|
||||
"{amount} more": "Button text to load more replies in the comments app",
|
||||
"{amount} off": "Portal offer page, showing a discount",
|
||||
"{amount} off for first {number} months.": "Portal offer page, showing a discount",
|
||||
"{amount} off for first {period}.": "Portal offer page, showing a discount",
|
||||
"{amount} off forever.": "Portal offer page, showing a discount",
|
||||
"{date}": "This will appear at the top of the newsletter, as the publication date. No changes needed, usually.",
|
||||
"{discount}% discount": "Label for a discounted tier in portal",
|
||||
"{memberEmail} will no longer receive emails when someone replies to your comments.": "Shown when a member unsubscribes from comment replies",
|
||||
"{memberEmail} will no longer receive this newsletter.": "Shown when a member unsubscribes from a newsletter",
|
||||
"{memberEmail} will no longer receive {newsletterName} newsletter.": "Shown when a member unsubscribes from a newsletter",
|
||||
"{months} months": "Duration label for a multiple-month subscription",
|
||||
"{months} months free": "Retention offer discount badge shown during subscription cancellation",
|
||||
"{trialDays} days free": "Portal - label for a free trial that lasts a specific number of days",
|
||||
"{years} years": "Duration label for a multiple-year subscription"
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"name": "@tryghost/i18n",
|
||||
"version": "0.0.0",
|
||||
"repository": "https://github.com/TryGhost/Ghost/tree/main/ghost/i18n",
|
||||
"author": "Ghost Foundation",
|
||||
"private": true,
|
||||
"main": "index.js",
|
||||
"exports": {
|
||||
".": "./index.js",
|
||||
"./lib/locale-data.json": "./lib/locale-data.json"
|
||||
},
|
||||
"types": "./build/i18n.d.ts",
|
||||
"scripts": {
|
||||
"dev": "echo \"Implement me!\"",
|
||||
"test:base": "NODE_ENV=testing c8 --include index.js --include lib --check-coverage --100 --reporter text --reporter cobertura -- mocha --reporter dot './test/**/*.test.js'",
|
||||
"test": "pnpm test:base && pnpm translate",
|
||||
"lint:code": "eslint *.js lib/ --ext .js --cache",
|
||||
"lint": "pnpm lint:code && pnpm lint:test && pnpm lint:translations",
|
||||
"lint:test": "eslint -c test/.eslintrc.js test/ --ext .js --cache",
|
||||
"lint:translations": "node ./test/i18n.lint.js",
|
||||
"translate": "pnpm translate:ghost && pnpm translate:portal && pnpm translate:signup-form && pnpm translate:comments && pnpm translate:search && node generate-context.js",
|
||||
"translate:ghost": "NAMESPACE=ghost i18next '../core/core/{frontend,server,shared}/**/*.{js,jsx}' '../core/core/server/services/email-rendering/partials/**/*.hbs' '../core/core/server/services/email-service/email-templates/**/*.hbs' '../core/core/server/services/comments/email-templates/**/*.hbs' '../core/core/server/services/member-welcome-emails/email-templates/**/*.hbs'",
|
||||
"translate:portal": "NAMESPACE=portal i18next '../../apps/portal/src/**/*.{js,jsx}'",
|
||||
"translate:signup-form": "NAMESPACE=signup-form i18next '../../apps/signup-form/src/**/*.{ts,tsx}'",
|
||||
"translate:comments": "NAMESPACE=comments i18next '../../apps/comments-ui/src/**/*.{ts,tsx}'",
|
||||
"translate:search": "NAMESPACE=search i18next '../../apps/sodo-search/src/**/*.{js,jsx,ts,tsx}'"
|
||||
},
|
||||
"files": [
|
||||
"index.js",
|
||||
"lib",
|
||||
"locales"
|
||||
],
|
||||
"devDependencies": {
|
||||
"c8": "10.1.3",
|
||||
"glob": "^13.0.6",
|
||||
"i18next-parser": "8.13.0",
|
||||
"mocha": "11.7.5"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tryghost/debug": "0.1.40",
|
||||
"i18next": "23.16.8"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
module.exports = {
|
||||
plugins: ['ghost'],
|
||||
extends: [
|
||||
'plugin:ghost/test'
|
||||
],
|
||||
rules: {
|
||||
// Enforce kebab-case (lowercase with hyphens) for all filenames
|
||||
'ghost/filenames/match-regex': ['error', '^[a-z0-9.-]+$', false]
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,143 @@
|
||||
{
|
||||
"overrides": {
|
||||
"ghost/i18n/no-undefined-variables": [
|
||||
{
|
||||
"file": "da/portal.json",
|
||||
"key": "Try free for {amount} days, then {originalPrice}.",
|
||||
"comment": "FIXME: This translation doesn't work and needs to be updated"
|
||||
},
|
||||
{
|
||||
"file": "de-CH/portal.json",
|
||||
"key": "Memberships unavailable, contact the owner for access.",
|
||||
"comment": "FIXME: This translation doesn't work and needs to be updated"
|
||||
},
|
||||
{
|
||||
"file": "de-CH/portal.json",
|
||||
"key": "We couldn't unsubscribe you as the email address was not found. Please contact the site owner.",
|
||||
"comment": "FIXME: This translation doesn't work and needs to be updated"
|
||||
},
|
||||
{
|
||||
"file": "is/portal.json",
|
||||
"key": "Your subscription will renew on {renewalDate}",
|
||||
"comment": "FIXME: This translation doesn't work and needs to be updated"
|
||||
},
|
||||
{
|
||||
"file": "lv/portal.json",
|
||||
"key": "There was a problem submitting your feedback. Please try again a little later.",
|
||||
"comment": "FIXME: This translation doesn't work and needs to be updated"
|
||||
},
|
||||
{
|
||||
"file": "si/portal.json",
|
||||
"key": "Your subscription will renew on {renewalDate}",
|
||||
"comment": "FIXME: This translation doesn't work and needs to be updated"
|
||||
},
|
||||
{
|
||||
"file": "si/portal.json",
|
||||
"key": "Your subscription will start on {subscriptionStart}",
|
||||
"comment": "FIXME: This translation doesn't work and needs to be updated"
|
||||
},
|
||||
{
|
||||
"file": "th/portal.json",
|
||||
"key": "Try free for {amount} days, then {originalPrice}.",
|
||||
"comment": "FIXME: This translation doesn't work and needs to be updated"
|
||||
}
|
||||
],
|
||||
"ghost/i18n/no-unused-variables": [
|
||||
{
|
||||
"file": "da/portal.json",
|
||||
"key": "Try free for {amount} days, then {originalPrice}.",
|
||||
"comment": "FIXME: This error was automatically ignored. Please update the translation, or explain why a variable is ignored"
|
||||
},
|
||||
{
|
||||
"file": "de-CH/ghost.json",
|
||||
"key": "Confirm your email update for {siteTitle}!",
|
||||
"comment": "FIXME: This error was automatically ignored. Please update the translation, or explain why a variable is ignored"
|
||||
},
|
||||
{
|
||||
"file": "de-CH/ghost.json",
|
||||
"key": "Confirm your subscription to {siteTitle}",
|
||||
"comment": "FIXME: This error was automatically ignored. Please update the translation, or explain why a variable is ignored"
|
||||
},
|
||||
{
|
||||
"file": "de-CH/portal.json",
|
||||
"key": "Unsubscribing from emails will not cancel your paid subscription to {title}",
|
||||
"comment": "FIXME: This error was automatically ignored. Please update the translation, or explain why a variable is ignored"
|
||||
},
|
||||
{
|
||||
"file": "de/portal.json",
|
||||
"key": "Start {amount}-day free trial",
|
||||
"comment": "translated text doesn't fit on the button"
|
||||
},
|
||||
{
|
||||
"file": "is/portal.json",
|
||||
"key": "{trialDays} days free",
|
||||
"comment": "FIXME: This error was automatically ignored. Please update the translation, or explain why a variable is ignored"
|
||||
},
|
||||
{
|
||||
"file": "is/portal.json",
|
||||
"key": "Your subscription will renew on {renewalDate}",
|
||||
"comment": "FIXME: This error was automatically ignored. Please update the translation, or explain why a variable is ignored"
|
||||
},
|
||||
{
|
||||
"file": "ja/ghost.json",
|
||||
"key": "Confirm your email update for {siteTitle}!",
|
||||
"comment": "FIXME: This error was automatically ignored. Please update the translation, or explain why a variable is ignored"
|
||||
},
|
||||
{
|
||||
"file": "ja/ghost.json",
|
||||
"key": "Tap the link below to complete the signup process for {siteTitle}, and be automatically signed in:",
|
||||
"comment": "FIXME: This error was automatically ignored. Please update the translation, or explain why a variable is ignored"
|
||||
},
|
||||
{
|
||||
"file": "lt/portal.json",
|
||||
"key": "Renews at {price}.",
|
||||
"comment": "FIXME: This error was automatically ignored. Please update the translation, or explain why a variable is ignored"
|
||||
},
|
||||
{
|
||||
"file": "nn/portal.json",
|
||||
"key": "{amount} off",
|
||||
"comment": "FIXME: This error was automatically ignored. Please update the translation, or explain why a variable is ignored"
|
||||
},
|
||||
{
|
||||
"file": "nn/portal.json",
|
||||
"key": "{discount}% discount",
|
||||
"comment": "FIXME: This error was automatically ignored. Please update the translation, or explain why a variable is ignored"
|
||||
},
|
||||
{
|
||||
"file": "pt/comments.json",
|
||||
"key": "{amount} more",
|
||||
"comment": "FIXME: This error was automatically ignored. Please update the translation, or explain why a variable is ignored"
|
||||
},
|
||||
{
|
||||
"file": "si/portal.json",
|
||||
"key": "Your subscription will renew on {renewalDate}",
|
||||
"comment": "FIXME: This error was automatically ignored. Please update the translation, or explain why a variable is ignored"
|
||||
},
|
||||
{
|
||||
"file": "si/portal.json",
|
||||
"key": "Your subscription will start on {subscriptionStart}",
|
||||
"comment": "FIXME: This error was automatically ignored. Please update the translation, or explain why a variable is ignored"
|
||||
},
|
||||
{
|
||||
"file": "sk/ghost.json",
|
||||
"key": "By {authors}",
|
||||
"comment": "FIXME: This error was automatically ignored. Please update the translation, or explain why a variable is ignored"
|
||||
},
|
||||
{
|
||||
"file": "sq/ghost.json",
|
||||
"key": "Sign in to {siteTitle}",
|
||||
"comment": "FIXME: This error was automatically ignored. Please update the translation, or explain why a variable is ignored"
|
||||
},
|
||||
{
|
||||
"file": "sq/portal.json",
|
||||
"key": "Expires {expiryDate}",
|
||||
"comment": "FIXME: This error was automatically ignored. Please update the translation, or explain why a variable is ignored"
|
||||
},
|
||||
{
|
||||
"file": "th/portal.json",
|
||||
"key": "Try free for {amount} days, then {originalPrice}.",
|
||||
"comment": "FIXME: This error was automatically ignored. Please update the translation, or explain why a variable is ignored"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,489 @@
|
||||
// @ts-check
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs/promises');
|
||||
const path = require('path');
|
||||
const {ESLint} = require('eslint');
|
||||
const {glob} = require('glob');
|
||||
const {readFileSync, writeFileSync} = require('fs');
|
||||
|
||||
const LOCALES_ROOT = path.join(__dirname, '..', 'locales');
|
||||
|
||||
/**
|
||||
* @typedef {{
|
||||
results: import('eslint').ESLint.LintResult[];
|
||||
errorCount: number;
|
||||
ignoreAllFlagProvidedWithoutFixFlag: boolean;
|
||||
}} LintSummary
|
||||
|
||||
* @typedef {
|
||||
'ghost/i18n/no-unused-variables' |
|
||||
'ghost/i18n/no-undefined-variables' |
|
||||
'ghost/i18n/no-invalid-translations' |
|
||||
'ghost/i18n/no-unused-ignores'
|
||||
} GhostI18nLintRule
|
||||
*/
|
||||
|
||||
class File {
|
||||
/**
|
||||
* @param {string} filePath
|
||||
* @param {string} source
|
||||
*/
|
||||
constructor(filePath, source) {
|
||||
this.path = filePath;
|
||||
this.relativePath = path.relative(LOCALES_ROOT, filePath);
|
||||
this.source = source;
|
||||
this.parsed = JSON.parse(source);
|
||||
this.locale = this._extractLocale(filePath);
|
||||
/** @private */
|
||||
this._analysis = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the probabilistic position of the given text in the file.
|
||||
* @param {string} text
|
||||
* @param {'key' | 'value'} textType
|
||||
* @returns {{line: number, column: number}}
|
||||
*/
|
||||
getPosition(text, textType) {
|
||||
return this._analyzedSource()[textType][text] ?? {line: 0, column: 0};
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a probabilistic location of keys and values in the file
|
||||
* Assumes the file contains one key/value pair per line
|
||||
* @private
|
||||
*/
|
||||
_analyzedSource() {
|
||||
if (this._analysis) {
|
||||
return this._analysis;
|
||||
}
|
||||
|
||||
const keys = {};
|
||||
const values = {};
|
||||
|
||||
const lines = this.source.split('\n');
|
||||
const kvSeparator = /"\s*:\s*"/;
|
||||
lines.shift();
|
||||
lines.pop();
|
||||
|
||||
// Since we shifted the array, start at 1
|
||||
let index = 1;
|
||||
for (const line of lines) {
|
||||
index += 1;
|
||||
const [keyMatch, valueMatch] = line.split(kvSeparator);
|
||||
if (!keyMatch || !valueMatch) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const key = keyMatch.split('"')[1];
|
||||
const value = valueMatch.split('"')[0];
|
||||
keys[key] = {line: index, column: line.indexOf(key)};
|
||||
values[value] = {line: index, column: line.indexOf(value, keyMatch.length + 1)};
|
||||
}
|
||||
|
||||
this._analysis = {key: keys, value: values};
|
||||
return this._analysis;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the locale from the file path. Assumes the file structure is $LOCALE_ROOT/$LOCAL/$NAMESPACE.json
|
||||
* @param {string} filePath
|
||||
*/
|
||||
_extractLocale(filePath) {
|
||||
const directory = path.dirname(filePath);
|
||||
return directory.slice(directory.lastIndexOf('/') + 1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads and transforms an ignore file into a Map.
|
||||
* Keys follow the format of `rule:locale/namespace:key`.
|
||||
* The value is the index which can be used for updating the ignore file.
|
||||
* @param {File} ignoreFile
|
||||
*/
|
||||
function parseIgnores(ignoreFile) {
|
||||
const response = new Map();
|
||||
ignoreFile.parsed.overrides ??= {};
|
||||
const ignores = ignoreFile.parsed.overrides;
|
||||
for (const [rule, ignored] of Object.entries(ignores)) {
|
||||
assert(Array.isArray(ignored), `Expected .overrides.${rule} to be an Array`);
|
||||
for (const [index, ignore] of ignored.entries()) {
|
||||
assert(typeof ignore === 'object', `Expected .overrides.${rule}[${index}] to be an Object`);
|
||||
assert(typeof ignore.file === 'string', `Expected .overrides.${rule}[${index}].file to be a String`);
|
||||
assert(typeof ignore.key === 'string', `Expected .overrides.${rule}[${index}].key to be a String`);
|
||||
assert(typeof ignore.comment === 'string', `Expected .overrides.${rule}[${index}].comment to be a String`);
|
||||
assert(ignore.comment, `Expected .overrides.${rule}[${index}].comment to contain a comment`);
|
||||
|
||||
response.set(`${rule}:${ignore.file}:${ignore.key}`, index);
|
||||
}
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
class LinterContext {
|
||||
/**
|
||||
* @param {string} ignoreFile
|
||||
*/
|
||||
constructor(ignoreFile) {
|
||||
this.ignoreAll = process.argv.includes('--unsafe-ignore-all');
|
||||
this.applyFixes = process.argv.includes('--fix');
|
||||
|
||||
/** @type {LintSummary} */
|
||||
this.summary = {
|
||||
results: [],
|
||||
errorCount: 0,
|
||||
ignoreAllFlagProvidedWithoutFixFlag: this.ignoreAll && !this.applyFixes
|
||||
};
|
||||
|
||||
/** @type {File | null} */
|
||||
this.file = null;
|
||||
|
||||
this._cwd = process.cwd();
|
||||
|
||||
/** @type {import('eslint').ESLint.LintResult | null} */
|
||||
this._currentResult = null;
|
||||
this._currentResultIndex = -1;
|
||||
this._currentMessageIndex = 0;
|
||||
|
||||
this._ignoreFile = new File(ignoreFile, readFileSync(ignoreFile, 'utf8'));
|
||||
this._ignoredRules = parseIgnores(this._ignoreFile);
|
||||
this._unusedIgnores = new Map(this._ignoredRules);
|
||||
|
||||
/**
|
||||
* @type {{resultIndex: number; messageIndex: number; fix: (overrides: any) => void;}[]}
|
||||
*/
|
||||
this._fixes = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} message
|
||||
* @param {string} key
|
||||
* @param {GhostI18nLintRule} ruleName
|
||||
* @param {number} line
|
||||
* @param {number} column
|
||||
* @param {(ignores: any) => void} [fix]
|
||||
*/
|
||||
reportTranslationError(message, key, ruleName, line, column, fix = undefined) {
|
||||
const ignoreKey = `${ruleName}:${this.relativeFilePath}:${key}`;
|
||||
this._unusedIgnores.delete(ignoreKey);
|
||||
if (!this._ignoredRules.has(ignoreKey)) {
|
||||
this._reportError(message, ruleName, line, column, fix);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} text
|
||||
* @param {'key' | 'value'} textType
|
||||
*/
|
||||
getPositionForText(text, textType) {
|
||||
assert(this.file, 'setFile() should have been called');
|
||||
return this.file.getPosition(text, textType);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {File} file
|
||||
*/
|
||||
setFile(file) {
|
||||
this.file = file;
|
||||
this._currentMessageIndex = 0;
|
||||
this._currentResultIndex += 1;
|
||||
this.summary.errorCount += this._currentResult?.errorCount ?? 0;
|
||||
this._currentResult = {
|
||||
filePath: path.relative(this._cwd, file.path),
|
||||
messages: [],
|
||||
|
||||
errorCount: 0,
|
||||
warningCount: 0,
|
||||
fixableErrorCount: 0,
|
||||
fixableWarningCount: 0,
|
||||
usedDeprecatedRules: [],
|
||||
fatalErrorCount: 0,
|
||||
suppressedMessages: []
|
||||
};
|
||||
this.summary.results.push(this._currentResult);
|
||||
}
|
||||
|
||||
get relativeFilePath() {
|
||||
assert(this.file, 'setFile() should have been called');
|
||||
return this.file.relativePath;
|
||||
}
|
||||
|
||||
complete() {
|
||||
assert(this._currentResult, 'setFile() should have been called');
|
||||
this.summary.errorCount += this._currentResult.errorCount;
|
||||
|
||||
this._reportUnusedIgnores();
|
||||
|
||||
if (this.applyFixes) {
|
||||
this._applyFixes();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {string} message
|
||||
* @param {GhostI18nLintRule} ruleName
|
||||
* @param {number} line
|
||||
* @param {number} column
|
||||
* @param {(ignores: any) => void} [fix]
|
||||
*/
|
||||
_reportError(message, ruleName, line, column, fix = undefined) {
|
||||
assert(this._currentResult, '`setFile` should have been called');
|
||||
this._currentResult.errorCount += 1;
|
||||
this._currentMessageIndex = this._currentResult.messages.length;
|
||||
this._currentResult.messages.push({
|
||||
ruleId: ruleName,
|
||||
severity: 2, // Error
|
||||
message,
|
||||
line,
|
||||
column
|
||||
});
|
||||
|
||||
if (fix) {
|
||||
this._fixes.push({fix, resultIndex: this._currentResultIndex, messageIndex: this._currentMessageIndex});
|
||||
}
|
||||
}
|
||||
|
||||
/** @private */
|
||||
_reportUnusedIgnores() {
|
||||
this.setFile(this._ignoreFile);
|
||||
for (const [key, index] of this._unusedIgnores.entries()) {
|
||||
const [rule, file, translation] = key.split(':');
|
||||
this._reportError(
|
||||
`Index ${index} for rule "${rule}" is not used.\n\tFile: ./locales/${file}\n\tkey: "${translation}"`,
|
||||
'ghost/i18n/no-unused-ignores',
|
||||
0,
|
||||
0,
|
||||
ignores => ignores[rule][index] = null
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** @private */
|
||||
_applyFixes() {
|
||||
const newIgnoreFile = structuredClone(this._ignoreFile.parsed);
|
||||
const resultsToFilter = new Set();
|
||||
for (const {fix, resultIndex, messageIndex} of this._fixes) {
|
||||
this.summary.errorCount -= 1;
|
||||
fix(newIgnoreFile.overrides);
|
||||
// @ts-expect-error will be filtered afterwards
|
||||
this.summary.results[resultIndex].messages[messageIndex] = null;
|
||||
resultsToFilter.add(resultIndex);
|
||||
}
|
||||
|
||||
console.log(`Applied ${this._fixes.length} fixes`); // eslint-disable-line no-console
|
||||
|
||||
for (const resultIndex of resultsToFilter) {
|
||||
this.summary.results[resultIndex].messages = this.summary.results[resultIndex].messages.filter(Boolean);
|
||||
}
|
||||
|
||||
for (const [rule, ignored] of Object.entries(newIgnoreFile.overrides)) {
|
||||
const updatedValue = ignored.filter(Boolean);
|
||||
if (updatedValue.length === 0) {
|
||||
delete newIgnoreFile.overrides[rule];
|
||||
} else {
|
||||
newIgnoreFile.overrides[rule] = updatedValue;
|
||||
}
|
||||
}
|
||||
|
||||
const sortedOverrideKeys = Array.from(Object.keys(newIgnoreFile.overrides));
|
||||
sortedOverrideKeys.sort();
|
||||
|
||||
const newOverrides = {};
|
||||
for (const rule of sortedOverrideKeys) {
|
||||
newOverrides[rule] = newIgnoreFile.overrides[rule];
|
||||
}
|
||||
|
||||
newIgnoreFile.overrides = newOverrides;
|
||||
|
||||
writeFileSync(this._ignoreFile.path, JSON.stringify(newIgnoreFile, null, 4));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats and logs the lint results. If there are any errors, the exit code will be 1
|
||||
* @param {LintSummary} summary
|
||||
*/
|
||||
async function exitWithSummary(summary) {
|
||||
const formatter = await new ESLint().loadFormatter();
|
||||
const formattedResults = await formatter.format(summary.results, {cwd: '', rulesMeta: {}});
|
||||
|
||||
if (formattedResults) {
|
||||
console.log(formattedResults); // eslint-disable-line no-console
|
||||
}
|
||||
|
||||
if (summary.ignoreAllFlagProvidedWithoutFixFlag) {
|
||||
console.warn( // eslint-disable-line no-console
|
||||
'--unsafe-ignore-all was provided without --fix; errors were not ignored'
|
||||
);
|
||||
}
|
||||
|
||||
if (summary.errorCount > 0) {
|
||||
console.log( // eslint-disable-line no-console
|
||||
`\nNote: Since JSON doesn't support comments, use test/i18n-ignore.json to waive messages.\n\n`
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts translations variables and any invalid interpolations from a string
|
||||
* @param {string} string the translation to parse
|
||||
*/
|
||||
function parseTranslationString(string) {
|
||||
const START = 0;
|
||||
const IN_VARIABLE = 1;
|
||||
const errors = [];
|
||||
|
||||
let state = START;
|
||||
let currentVariable = '';
|
||||
let variableStartColumn = 0;
|
||||
const variables = new Map();
|
||||
|
||||
// Use 1-indexed iterators since column is used a lot more than index
|
||||
for (let column = 1; column <= string.length; column++) {
|
||||
const character = string[column - 1];
|
||||
if (character === '{') {
|
||||
if (state === START) {
|
||||
state = IN_VARIABLE;
|
||||
// The brace is at the current column; the variable starts at the next column
|
||||
variableStartColumn = column + 1;
|
||||
} else {
|
||||
errors.push({message: 'Unexpected "{" in variable', column});
|
||||
}
|
||||
} else if (character === '}') {
|
||||
if (state === IN_VARIABLE) {
|
||||
state = START;
|
||||
variables.set(currentVariable, variableStartColumn);
|
||||
currentVariable = '';
|
||||
} else {
|
||||
errors.push({message: 'Unexpected "}" in string', column});
|
||||
}
|
||||
} else if (state === IN_VARIABLE) {
|
||||
currentVariable += character;
|
||||
}
|
||||
}
|
||||
|
||||
if (state === IN_VARIABLE) {
|
||||
errors.push({message: 'Unclosed {', column: variableStartColumn});
|
||||
}
|
||||
|
||||
return {variables, errors};
|
||||
}
|
||||
|
||||
async function *getTranslationFiles() {
|
||||
const globs = await glob(`${LOCALES_ROOT}/*/*.json`);
|
||||
for (const translationFile of globs) {
|
||||
yield translationFile;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a translation error to {ignores}
|
||||
* @param {Record<string, any>} ignores
|
||||
* @param {string} rule
|
||||
* @param {string} file
|
||||
* @param {string} key
|
||||
*/
|
||||
function ignoreTranslationError(ignores, rule, file, key) {
|
||||
ignores[rule] ??= [];
|
||||
ignores[rule].push({
|
||||
file,
|
||||
key,
|
||||
comment: 'FIXME: This error was automatically ignored. Please update the translation or this comment.'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reports all errors for a translation pair
|
||||
* @param {string} key the untranslated key to analyze
|
||||
* @param {string} translated the translated value to analyze
|
||||
* @param {LinterContext} context
|
||||
*/
|
||||
function analyzeSingleTranslation(key, translated, context) {
|
||||
const {errors: keyErrors, variables: defines} = parseTranslationString(key);
|
||||
|
||||
// Report key parsing errors only for English translations.
|
||||
// I18next is responsible for keeping all other locales in sync.
|
||||
if (context.file?.locale === 'en') {
|
||||
const {line, column} = context.getPositionForText(key, 'key');
|
||||
for (const error of keyErrors) {
|
||||
const rule = 'ghost/i18n/no-invalid-translations';
|
||||
context.reportTranslationError(error.message, key, rule, line, column + error.column);
|
||||
}
|
||||
}
|
||||
|
||||
if (!translated) {
|
||||
return;
|
||||
}
|
||||
|
||||
const {errors, variables: used} = parseTranslationString(translated);
|
||||
|
||||
{
|
||||
const {line, column} = context.getPositionForText(key, 'value');
|
||||
for (const error of errors) {
|
||||
const rule = 'ghost/i18n/no-invalid-translations';
|
||||
context.reportTranslationError(error.message, key, rule, line, column + error.column);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [define, columnInKey] of defines.entries()) {
|
||||
// Use delete to remove the variable so `used` will only contain unused variables after this loop
|
||||
if (!used.delete(define)) {
|
||||
const rule = 'ghost/i18n/no-unused-variables';
|
||||
const file = context.relativeFilePath;
|
||||
const {line, column} = context.getPositionForText(key, 'key');
|
||||
context.reportTranslationError(
|
||||
`Translation does not use variable "${define}"`,
|
||||
key,
|
||||
rule,
|
||||
line,
|
||||
column + columnInKey,
|
||||
context.ignoreAll ? ignores => ignoreTranslationError(ignores, rule, file, key) : undefined
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const {line, column} = context.getPositionForText(translated, 'value');
|
||||
for (const [unknownVariable, columnInTranslation] of used.entries()) {
|
||||
const rule = 'ghost/i18n/no-undefined-variables';
|
||||
const file = context.relativeFilePath;
|
||||
context.reportTranslationError(
|
||||
`Translation uses unknown variable "${unknownVariable}"`,
|
||||
key,
|
||||
rule,
|
||||
line,
|
||||
column + columnInTranslation,
|
||||
context.ignoreAll ? ignores => ignoreTranslationError(ignores, rule, file, key) : undefined
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function analyze() {
|
||||
const context = new LinterContext(path.join(__dirname, './i18n-ignores.json'));
|
||||
for await (const translationFile of await getTranslationFiles()) {
|
||||
const file = new File(translationFile, await fs.readFile(translationFile, 'utf8'));
|
||||
context.setFile(file);
|
||||
for (const [key, translated] of Object.entries(file.parsed)) {
|
||||
analyzeSingleTranslation(key, translated, context);
|
||||
}
|
||||
}
|
||||
|
||||
context.complete();
|
||||
|
||||
return context.summary;
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
analyze().then(results => exitWithSummary(results)).catch((error) => {
|
||||
console.error(error); // eslint-disable-line no-console
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
analyze
|
||||
};
|
||||
@@ -0,0 +1,424 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs/promises');
|
||||
const path = require('path');
|
||||
const fsExtra = require('fs-extra');
|
||||
const i18n = require('../');
|
||||
|
||||
describe('i18n', function () {
|
||||
it('does not have too-long strings for the Stripe personal note label', async function () {
|
||||
for (const locale of i18n.SUPPORTED_LOCALES) {
|
||||
const translationFile = require(path.join(`../locales/`, locale, 'portal.json'));
|
||||
|
||||
if (translationFile['Add a personal note']) {
|
||||
assert(translationFile['Add a personal note'].length <= 255, `[${locale}/portal.json] Stripe personal note label is too long`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('is uses default export if available', async function () {
|
||||
const translationFile = require(path.join(`../locales/`, 'nl', 'portal.json'));
|
||||
translationFile.Name = undefined;
|
||||
translationFile.default = {
|
||||
Name: 'Naam'
|
||||
};
|
||||
|
||||
const t = i18n('nl', 'portal').t;
|
||||
assert.equal(t('Name'), 'Naam');
|
||||
});
|
||||
|
||||
describe('Can use Portal resources', function () {
|
||||
describe('Dutch', function () {
|
||||
let t;
|
||||
|
||||
before(function () {
|
||||
t = i18n('nl', 'portal').t;
|
||||
});
|
||||
|
||||
it('can translate `Name`', function () {
|
||||
assert.equal(t('Name'), 'Naam');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Can use Signup-form resources', function () {
|
||||
describe('Afrikaans', function () {
|
||||
let t;
|
||||
|
||||
before(function () {
|
||||
t = i18n('af', 'signup-form').t;
|
||||
});
|
||||
|
||||
it('can translate `Now check your email!`', function () {
|
||||
assert.equal(t('Now check your email!'), 'Kyk nou in jou e-pos!');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Fallback when no language is chosen will be english', function () {
|
||||
describe('English fallback', function () {
|
||||
let t;
|
||||
before(function () {
|
||||
t = i18n().t;
|
||||
});
|
||||
it('can translate with english when no language selected', function () {
|
||||
assert.equal(t('Back'), 'Back');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Fallback will be nb when no is chosen', function () {
|
||||
describe('Norwegian bokmål fallback', function () {
|
||||
let t;
|
||||
before(function () {
|
||||
t = i18n('no', 'portal').t;
|
||||
});
|
||||
it('Norwegian bokmål used when no is chosen', function () {
|
||||
assert.equal(t('Yearly'), 'Årlig');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Language will be nb when nb is chosen', function () {
|
||||
describe('Norwegian bokmål', function () {
|
||||
let t;
|
||||
before(function () {
|
||||
t = i18n('nb', 'portal').t;
|
||||
});
|
||||
it('Norwegian bokmål used when "nb" is chosen', function () {
|
||||
assert.equal(t('Yearly'), 'Årlig');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Language is properly "nn" when "nn" is chosen', function () {
|
||||
describe('Norwegian Nynorsk', function () {
|
||||
let t;
|
||||
before(function () {
|
||||
t = i18n('nn', 'portal').t;
|
||||
});
|
||||
it('Norwegian Nynorsk used when selected', function () {
|
||||
assert.equal(t('Yearly'), 'Årleg');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('directories and locales in i18n.js will match', function () {
|
||||
it('should have a key for each directory in the locales directory', async function () {
|
||||
const locales = await fs.readdir(path.join(__dirname, '../locales'));
|
||||
const supportedLocales = i18n.SUPPORTED_LOCALES;
|
||||
|
||||
for (const locale of locales) {
|
||||
if (locale !== 'context.json') {
|
||||
assert(supportedLocales.includes(locale), `The locale ${locale} is not in the list of supported locales`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('should have a directory for each key in lib/i18n.js', async function () {
|
||||
const supportedLocales = i18n.SUPPORTED_LOCALES;
|
||||
|
||||
for (const locale of supportedLocales) {
|
||||
const localeDir = path.join(__dirname, `../locales/${locale}`);
|
||||
const stats = await fs.stat(localeDir);
|
||||
assert(stats.isDirectory(), `The locale ${locale} does not have a directory`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('newsletter i18n', function () {
|
||||
it('should be able to translate and interpolate a date', async function () {
|
||||
const t = i18n('fr', 'ghost').t;
|
||||
assert.equal(t('Your subscription will renew on {date}.', {date: '8 Oct 2024'}), 'Votre abonnement sera renouvelé le 8 Oct 2024.');
|
||||
});
|
||||
});
|
||||
describe('it gracefully falls back to en if a file is missing', function () {
|
||||
it('should be able to translate a key that is missing in the locale', async function () {
|
||||
const resources = i18n.generateResources(['xx'], 'portal');
|
||||
const englishResources = i18n.generateResources(['en'], 'portal');
|
||||
assert.deepEqual(resources.xx, englishResources.en);
|
||||
});
|
||||
});
|
||||
|
||||
// The goal of the test below (TODO) is to make sure that new keys get added to context.json with
|
||||
// enough information to be useful to translators. The person best positioned to do this is
|
||||
// the person who added the key. However, it's complicated by the order that translate and test
|
||||
// currently run in, so leaving it disabled for now.
|
||||
/*describe('context.json is valid', function () {
|
||||
it('should not contain any empty values', function () {
|
||||
const context = require('../locales/context.json');
|
||||
|
||||
function checkForEmptyValues(obj, keypath = '') {
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
const currentPath = keypath ? `${keypath}.${key}` : key;
|
||||
|
||||
if (value === null || value === undefined || value === '') {
|
||||
assert.fail(`Empty value found at ${currentPath}. If you added a new key for translation, please add it to the ghost/i18n/locales/context.json file.`);
|
||||
}
|
||||
|
||||
if (typeof value === 'object' && value !== null) {
|
||||
checkForEmptyValues(value, currentPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
checkForEmptyValues(context);
|
||||
});
|
||||
}); */
|
||||
|
||||
// i18n theme translations when feature flag is enabled
|
||||
describe('theme resources', function () {
|
||||
let themeLocalesPath;
|
||||
let cleanup;
|
||||
|
||||
beforeEach(async function () {
|
||||
// Create a temporary theme locales directory
|
||||
themeLocalesPath = path.join(__dirname, 'temp-theme-locales');
|
||||
await fsExtra.ensureDir(themeLocalesPath);
|
||||
cleanup = async () => {
|
||||
await fsExtra.remove(themeLocalesPath);
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(async function () {
|
||||
await cleanup();
|
||||
});
|
||||
|
||||
it('loads translations from theme locales directory', async function () {
|
||||
// Create test translation files
|
||||
const enContent = {
|
||||
'Read more': 'Read more',
|
||||
Subscribe: 'Subscribe'
|
||||
};
|
||||
const frContent = {
|
||||
'Read more': 'Lire plus',
|
||||
Subscribe: 'S\'abonner'
|
||||
};
|
||||
|
||||
await fsExtra.writeJson(path.join(themeLocalesPath, 'en.json'), enContent);
|
||||
await fsExtra.writeJson(path.join(themeLocalesPath, 'fr.json'), frContent);
|
||||
|
||||
const t = i18n('fr', 'theme', {themePath: themeLocalesPath}).t;
|
||||
assert.equal(t('Read more'), 'Lire plus');
|
||||
assert.equal(t('Subscribe'), 'S\'abonner');
|
||||
});
|
||||
|
||||
it('falls back to en when translation is missing', async function () {
|
||||
// Create only English translation file
|
||||
const enContent = {
|
||||
'Read more': 'Read more',
|
||||
Subscribe: 'Subscribe'
|
||||
};
|
||||
await fsExtra.writeJson(path.join(themeLocalesPath, 'en.json'), enContent);
|
||||
|
||||
const t = i18n('fr', 'theme', {themePath: themeLocalesPath}).t;
|
||||
assert.equal(t('Read more'), 'Read more');
|
||||
assert.equal(t('Subscribe'), 'Subscribe');
|
||||
});
|
||||
|
||||
it('uses empty translations when no files exist', async function () {
|
||||
const t = i18n('fr', 'theme', {themePath: themeLocalesPath}).t;
|
||||
assert.equal(t('Read more'), 'Read more');
|
||||
assert.equal(t('Subscribe'), 'Subscribe');
|
||||
});
|
||||
|
||||
it('handles invalid JSON files gracefully', async function () {
|
||||
// Create invalid JSON file
|
||||
await fsExtra.writeFile(path.join(themeLocalesPath, 'fr.json'), 'invalid json');
|
||||
|
||||
const t = i18n('fr', 'theme', {themePath: themeLocalesPath}).t;
|
||||
assert.equal(t('Read more'), 'Read more');
|
||||
assert.equal(t('Subscribe'), 'Subscribe');
|
||||
});
|
||||
|
||||
it('handles errors when both requested locale and English fallback files are invalid', async function () {
|
||||
// Create invalid JSON files for both requested locale and English fallback
|
||||
await fsExtra.writeFile(path.join(themeLocalesPath, 'de.json'), 'invalid json');
|
||||
await fsExtra.writeFile(path.join(themeLocalesPath, 'en.json'), 'also invalid json');
|
||||
|
||||
const t = i18n('de', 'theme', {themePath: themeLocalesPath}).t;
|
||||
|
||||
// Should fall back to returning the key itself since both files failed
|
||||
assert.equal(t('Read more'), 'Read more');
|
||||
assert.equal(t('Subscribe'), 'Subscribe');
|
||||
});
|
||||
|
||||
it('handles theme files with TypeScript default export structure', async function () {
|
||||
// Create a theme translation file that mimics TypeScript's default export behavior
|
||||
// where translations are nested under a 'default' property
|
||||
const themeContent = {
|
||||
'Read more': 'Read more directly',
|
||||
Subscribe: 'Subscribe directly',
|
||||
default: {
|
||||
'Welcome message': 'Welcome from default',
|
||||
'Footer text': 'Footer from default'
|
||||
}
|
||||
};
|
||||
|
||||
await fsExtra.writeJson(path.join(themeLocalesPath, 'en.json'), themeContent);
|
||||
|
||||
const t = i18n('en', 'theme', {themePath: themeLocalesPath}).t;
|
||||
|
||||
// Should be able to access both direct properties and properties from the default export
|
||||
assert.equal(t('Read more'), 'Read more directly');
|
||||
assert.equal(t('Subscribe'), 'Subscribe directly');
|
||||
assert.equal(t('Welcome message'), 'Welcome from default');
|
||||
assert.equal(t('Footer text'), 'Footer from default');
|
||||
});
|
||||
|
||||
it('handles theme files with non-object default export', async function () {
|
||||
// Create a theme translation file where the default export is not an object
|
||||
const themeContent = {
|
||||
'Read more': 'Read more',
|
||||
Subscribe: 'Subscribe',
|
||||
default: 'not an object'
|
||||
};
|
||||
|
||||
await fsExtra.writeJson(path.join(themeLocalesPath, 'en.json'), themeContent);
|
||||
|
||||
const t = i18n('en', 'theme', {themePath: themeLocalesPath}).t;
|
||||
|
||||
// Should only use direct properties, ignoring the non-object default export
|
||||
assert.equal(t('Read more'), 'Read more');
|
||||
assert.equal(t('Subscribe'), 'Subscribe');
|
||||
});
|
||||
|
||||
it('initializes i18next with correct configuration', async function () {
|
||||
const enContent = {
|
||||
'Read more': 'Read more'
|
||||
};
|
||||
await fsExtra.writeJson(path.join(themeLocalesPath, 'en.json'), enContent);
|
||||
|
||||
const instance = i18n('fr', 'theme', {themePath: themeLocalesPath});
|
||||
|
||||
// Verify i18next configuration
|
||||
assert.equal(instance.language, 'fr');
|
||||
assert.deepEqual(instance.options.ns, ['theme']);
|
||||
assert.equal(instance.options.defaultNS, 'theme');
|
||||
assert.equal(instance.options.fallbackLng.default[0], 'en');
|
||||
assert.equal(instance.options.returnEmptyString, false);
|
||||
|
||||
// Verify resources are loaded correctly
|
||||
const resources = instance.store.data;
|
||||
assert(resources.fr);
|
||||
assert(resources.fr.theme);
|
||||
assert.equal(resources.fr.theme['Read more'], 'Read more');
|
||||
});
|
||||
|
||||
it('interpolates variables in theme translations', async function () {
|
||||
const enContent = {
|
||||
'Welcome, {name}': 'Welcome, {name}',
|
||||
'Hello {firstName} {lastName}': 'Hello {firstName} {lastName}'
|
||||
};
|
||||
await fsExtra.writeJson(path.join(themeLocalesPath, 'en.json'), enContent);
|
||||
|
||||
const t = i18n('en', 'theme', {themePath: themeLocalesPath}).t;
|
||||
|
||||
// Test simple interpolation
|
||||
assert.equal(t('Welcome, {name}', {name: 'John'}), 'Welcome, John');
|
||||
|
||||
// Test multiple variables
|
||||
assert.equal(
|
||||
t('Hello {firstName} {lastName}', {firstName: 'John', lastName: 'Doe'}),
|
||||
'Hello John Doe'
|
||||
);
|
||||
});
|
||||
|
||||
it('uses single curly braces for theme namespace interpolation', async function () {
|
||||
const enContent = {
|
||||
'Welcome, {name}': 'Welcome, {name}'
|
||||
};
|
||||
await fsExtra.writeJson(path.join(themeLocalesPath, 'en.json'), enContent);
|
||||
|
||||
const t = i18n('en', 'theme', {themePath: themeLocalesPath}).t;
|
||||
assert.equal(t('Welcome, {name}', {name: 'John'}), 'Welcome, John');
|
||||
});
|
||||
|
||||
it('uses single curly braces for portal namespace interpolation', async function () {
|
||||
const t = i18n('en', 'portal').t;
|
||||
assert.equal(t('Welcome, {name}', {name: 'John'}), 'Welcome, John');
|
||||
});
|
||||
|
||||
it('uses single curly braces for ghost namespace interpolation', async function () {
|
||||
const t = i18n('en', 'ghost').t;
|
||||
assert.equal(t('Welcome, {name}', {name: 'John'}), 'Welcome, John');
|
||||
});
|
||||
|
||||
it('does not html encode interpolated values in the theme namespace', async function () {
|
||||
const enContent = {
|
||||
'Welcome, {name}': 'Welcome, {name}'
|
||||
};
|
||||
await fsExtra.writeJson(path.join(themeLocalesPath, 'en.json'), enContent);
|
||||
const t = i18n('en', 'theme', {themePath: themeLocalesPath}).t;
|
||||
assert.equal(t('Welcome, {name}', {name: '<b>John O\'Nolan</b>'}), 'Welcome, <b>John O\'Nolan</b>');
|
||||
});
|
||||
});
|
||||
|
||||
describe('i18next initialization', function () {
|
||||
it('initializes with correct default configuration', function () {
|
||||
const instance = i18n('en', 'portal');
|
||||
|
||||
// Verify basic configuration
|
||||
assert.equal(instance.language, 'en');
|
||||
assert.deepEqual(instance.options.ns, ['portal']);
|
||||
assert.equal(instance.options.defaultNS, 'portal');
|
||||
assert.equal(instance.options.fallbackLng.default[0], 'en');
|
||||
assert.equal(instance.options.returnEmptyString, false);
|
||||
assert.equal(instance.options.nsSeparator, false);
|
||||
assert.equal(instance.options.keySeparator, false);
|
||||
|
||||
// Verify interpolation configuration for portal namespace
|
||||
assert.equal(instance.options.interpolation.prefix, '{');
|
||||
assert.equal(instance.options.interpolation.suffix, '}');
|
||||
});
|
||||
|
||||
it('initializes with correct theme configuration', function () {
|
||||
const instance = i18n('en', 'theme', {themePath: '/path/to/theme'});
|
||||
|
||||
// Verify basic configuration
|
||||
assert.equal(instance.language, 'en');
|
||||
assert.deepEqual(instance.options.ns, ['theme']);
|
||||
assert.equal(instance.options.defaultNS, 'theme');
|
||||
assert.equal(instance.options.fallbackLng.default[0], 'en');
|
||||
assert.equal(instance.options.returnEmptyString, false);
|
||||
assert.equal(instance.options.nsSeparator, false);
|
||||
assert.equal(instance.options.keySeparator, false);
|
||||
|
||||
// Verify interpolation configuration for theme namespace
|
||||
assert.equal(instance.options.interpolation.prefix, '{');
|
||||
assert.equal(instance.options.interpolation.suffix, '}');
|
||||
});
|
||||
|
||||
it('initializes with correct newsletter (now ghost) configuration', function () {
|
||||
// note: we just merged newsletter into Ghost, so there might be some redundancy here
|
||||
const instance = i18n('en', 'ghost');
|
||||
|
||||
// Verify basic configuration
|
||||
assert.equal(instance.language, 'en');
|
||||
assert.deepEqual(instance.options.ns, ['ghost']);
|
||||
assert.equal(instance.options.defaultNS, 'ghost');
|
||||
assert.equal(instance.options.fallbackLng.default[0], 'en');
|
||||
assert.equal(instance.options.returnEmptyString, false);
|
||||
assert.equal(instance.options.nsSeparator, false);
|
||||
assert.equal(instance.options.keySeparator, false);
|
||||
|
||||
// Verify interpolation configuration for ghost namespace
|
||||
assert.equal(instance.options.interpolation.prefix, '{');
|
||||
assert.equal(instance.options.interpolation.suffix, '}');
|
||||
});
|
||||
|
||||
it('initializes with correct fallback language configuration', function () {
|
||||
const instance = i18n('no', 'portal');
|
||||
|
||||
// Verify Norwegian fallback chain
|
||||
assert.deepEqual(instance.options.fallbackLng.no, ['nb', 'en']);
|
||||
assert.deepEqual(instance.options.fallbackLng.default, ['en']);
|
||||
});
|
||||
|
||||
it('initializes with empty theme resources when no theme path provided', function () {
|
||||
const instance = i18n('en', 'theme');
|
||||
|
||||
// Verify empty theme resources
|
||||
assert.deepEqual(instance.store.data.en.theme, {});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
function extractVariables(str) {
|
||||
if (!str) {
|
||||
return new Set();
|
||||
}
|
||||
const regex = /\{([^}]+)\}/g;
|
||||
const variables = new Set();
|
||||
let match;
|
||||
while ((match = regex.exec(str)) !== null) {
|
||||
variables.add(match[1]);
|
||||
}
|
||||
return variables;
|
||||
}
|
||||
|
||||
function checkTranslationPair(key, value) {
|
||||
let result = [];
|
||||
// Skip checking if value is an empty string
|
||||
if (value === '') {
|
||||
return result;
|
||||
}
|
||||
|
||||
if (typeof key !== 'string') {
|
||||
return result;
|
||||
}
|
||||
|
||||
const keyVars = extractVariables(key);
|
||||
const valueVars = extractVariables(value);
|
||||
|
||||
// Check for variables in key but not in value
|
||||
for (const keyVar of keyVars) {
|
||||
if (!valueVars.has(keyVar)) {
|
||||
result.push('missingVariable');
|
||||
}
|
||||
}
|
||||
|
||||
// Check for variables in value but not in key
|
||||
for (const valueVar of valueVars) {
|
||||
if (!keyVars.has(valueVar)) {
|
||||
result.push('addedVariable');
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
checkTranslationPair
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
module.exports = {
|
||||
parser: '@typescript-eslint/parser',
|
||||
plugins: ['ghost'],
|
||||
extends: [
|
||||
'plugin:ghost/node'
|
||||
]
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2013-2026 Ghost Foundation
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,36 @@
|
||||
# Parse Email Address
|
||||
|
||||
Extract the local and domain parts of email address strings.
|
||||
|
||||
## Usage
|
||||
|
||||
```javascript
|
||||
parseEmailAddress('foo@example.com');
|
||||
// => {local: 'foo', domain: 'example.com'}
|
||||
|
||||
parseEmailAddress('invalid');
|
||||
// => null
|
||||
|
||||
parseEmailAddress('foo@中文.example');
|
||||
// => {local: 'foo', domain: 'xn--fiq228c.example'}
|
||||
```
|
||||
|
||||
- Domain names must have at least two labels. `example.com` is okay, `example` is not.
|
||||
- The top level domain must have at least two octets. `example.com` is okay, `example.x` is not.
|
||||
- There are various length limits:
|
||||
- The whole email is limited to 986 octets, per SMTP.
|
||||
- Domain names are limited to 253 octets, per SMTP.
|
||||
- Domain labels are limited to 63 octets, per DNS.
|
||||
|
||||
## Develop
|
||||
|
||||
This is a monorepo package.
|
||||
|
||||
Follow the instructions for the top-level repo.
|
||||
1. `git clone` this repo & `cd` into it as usual
|
||||
2. Run `pnpm` to install top-level dependencies.
|
||||
|
||||
## Test
|
||||
|
||||
- `pnpm lint` run just eslint
|
||||
- `pnpm test` run lint and tests
|
||||
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"name": "@tryghost/parse-email-address",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"repository": "https://github.com/TryGhost/Ghost/tree/main/ghost/parse-email-address",
|
||||
"author": "Ghost Foundation",
|
||||
"license": "MIT",
|
||||
"main": "build/index.js",
|
||||
"types": "build/index.d.ts",
|
||||
"scripts": {
|
||||
"dev": "tsc --watch --preserveWatchOutput --sourceMap",
|
||||
"build": "pnpm build:tsc",
|
||||
"build:tsc": "tsc",
|
||||
"test:unit": "NODE_ENV=testing c8 --src src --all --check-coverage --lines 100 --functions 100 --statements 100 --branches 80 --reporter text --reporter cobertura mocha --node-option import=tsx './test/**/*.test.ts'",
|
||||
"test": "pnpm test:types && pnpm test:unit",
|
||||
"test:types": "tsc --noEmit",
|
||||
"lint:code": "eslint src/ --ext .ts --cache",
|
||||
"lint": "pnpm lint:code && pnpm lint:test",
|
||||
"lint:test": "eslint -c test/.eslintrc.js test/ --ext .ts --cache"
|
||||
},
|
||||
"files": [
|
||||
"build"
|
||||
],
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "25.6.0",
|
||||
"c8": "10.1.3",
|
||||
"mocha": "11.7.5",
|
||||
"sinon": "21.0.1",
|
||||
"ts-node": "10.9.2",
|
||||
"tsx": "4.21.0",
|
||||
"typescript": "5.9.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"parse-email-address": "0.0.2"
|
||||
},
|
||||
"nx": {
|
||||
"targets": {
|
||||
"build": {
|
||||
"outputs": ["{projectRoot}/build"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import {parseEmailAddress as upstreamParseEmailAddress} from 'parse-email-address';
|
||||
import {domainToASCII} from 'node:url';
|
||||
|
||||
export const parseEmailAddress = (
|
||||
emailAddress: string
|
||||
): null | { local: string; domain: string } => {
|
||||
const upstreamParsed = upstreamParseEmailAddress(emailAddress);
|
||||
if (!upstreamParsed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const {user: local, domain: rawDomain} = upstreamParsed;
|
||||
|
||||
const domain = domainToASCII(rawDomain);
|
||||
if (!domain) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {local, domain};
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
module.exports = {
|
||||
parser: '@typescript-eslint/parser',
|
||||
plugins: ['ghost'],
|
||||
extends: [
|
||||
'plugin:ghost/test'
|
||||
]
|
||||
};
|
||||
@@ -0,0 +1,63 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {parseEmailAddress} from '../src';
|
||||
|
||||
describe('parseEmailAddress', function () {
|
||||
it('returns null for invalid email addresses', function () {
|
||||
const invalid = [
|
||||
// These aren't email addresses
|
||||
'',
|
||||
'foo',
|
||||
'example.com',
|
||||
'@example.com',
|
||||
// Bad spacing
|
||||
' foo@example.com',
|
||||
'foo@example.com ',
|
||||
'foo @example.com',
|
||||
'foo@example .com',
|
||||
// Too many @s
|
||||
'foo@bar@example.com',
|
||||
// Invalid user
|
||||
'a"b(c)d,e:f;g<h>i[j\\k]l@example.com',
|
||||
'just"not"right@example.com',
|
||||
'this is"not\\allowed@example.com',
|
||||
'x'.repeat(975) + '@example.com',
|
||||
// Invalid domains
|
||||
'foo@example',
|
||||
'foo@no_underscores.example',
|
||||
'foo@xn--iñvalid.com',
|
||||
'foo@' + 'x'.repeat(253) + '.yz',
|
||||
// IP domains
|
||||
'foo@[127.0.0.1]',
|
||||
'foo@[IPv6:::1]',
|
||||
'foo@[ipv6:::1]',
|
||||
// Tag domains
|
||||
'foo@[bar:Baz]'
|
||||
];
|
||||
for (const input of invalid) {
|
||||
assert.equal(parseEmailAddress(input), null, input);
|
||||
}
|
||||
});
|
||||
|
||||
it('returns the local and domain part of domains', function () {
|
||||
const testCases: [string, string, string][] = [
|
||||
// Basic cases
|
||||
['foo@example.com', 'foo', 'example.com'],
|
||||
['foo.bar@example.com', 'foo.bar', 'example.com'],
|
||||
['foo.bar+baz@example.com', 'foo.bar+baz', 'example.com'],
|
||||
// Unusual usernames
|
||||
['" "@example.com', '" "', 'example.com'],
|
||||
['"foo..bar"@example.com', '"foo..bar"', 'example.com'],
|
||||
['"<foo>"@example.com', '"<foo>"', 'example.com'],
|
||||
['"\\<foo\\>"@example.com', '"\\<foo\\>"', 'example.com'],
|
||||
['"foo@bar.example"@example.com', '"foo@bar.example"', 'example.com'],
|
||||
// Lowercasing
|
||||
['Foo@Example.COM', 'Foo', 'example.com'],
|
||||
// Non-ASCII
|
||||
['foo@中文.example', 'foo', 'xn--fiq228c.example'],
|
||||
['中文@example.com', '中文', 'example.com']
|
||||
];
|
||||
for (const [input, local, domain] of testCases) {
|
||||
assert.deepEqual(parseEmailAddress(input), {local, domain}, input);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
/* Visit https://aka.ms/tsconfig to read more about this file */
|
||||
|
||||
/* Projects */
|
||||
"incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */
|
||||
// "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */
|
||||
// "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */
|
||||
// "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */
|
||||
// "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */
|
||||
// "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */
|
||||
|
||||
/* Language and Environment */
|
||||
"target": "es2022", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */
|
||||
// "lib": ["es2019"], /* Specify a set of bundled library declaration files that describe the target runtime environment. */
|
||||
// "jsx": "preserve", /* Specify what JSX code is generated. */
|
||||
// "experimentalDecorators": true, /* Enable experimental support for legacy experimental decorators. */
|
||||
// "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */
|
||||
// "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */
|
||||
// "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */
|
||||
// "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */
|
||||
// "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */
|
||||
// "noLib": true, /* Disable including any library files, including the default lib.d.ts. */
|
||||
// "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */
|
||||
// "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */
|
||||
|
||||
/* Modules */
|
||||
"module": "commonjs", /* Specify what module code is generated. */
|
||||
"rootDir": "src", /* Specify the root folder within your source files. */
|
||||
// "moduleResolution": "node10", /* Specify how TypeScript looks up a file from a given module specifier. */
|
||||
// "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */
|
||||
// "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */
|
||||
// "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */
|
||||
// "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */
|
||||
"types": ["node"], /* Specify type package names to be included without being referenced in a source file. */
|
||||
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
|
||||
// "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */
|
||||
// "allowImportingTsExtensions": true, /* Allow imports to include TypeScript file extensions. Requires '--moduleResolution bundler' and either '--noEmit' or '--emitDeclarationOnly' to be set. */
|
||||
// "resolvePackageJsonExports": true, /* Use the package.json 'exports' field when resolving package imports. */
|
||||
// "resolvePackageJsonImports": true, /* Use the package.json 'imports' field when resolving imports. */
|
||||
// "customConditions": [], /* Conditions to set in addition to the resolver-specific defaults when resolving imports. */
|
||||
"resolveJsonModule": true, /* Enable importing .json files. */
|
||||
// "allowArbitraryExtensions": true, /* Enable importing files with any extension, provided a declaration file is present. */
|
||||
// "noResolve": true, /* Disallow 'import's, 'require's or '<reference>'s from expanding the number of files TypeScript should add to a project. */
|
||||
|
||||
/* JavaScript Support */
|
||||
// "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */
|
||||
// "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */
|
||||
// "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */
|
||||
|
||||
/* Emit */
|
||||
"declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */
|
||||
// "declarationMap": true, /* Create sourcemaps for d.ts files. */
|
||||
// "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */
|
||||
// "sourceMap": true, /* Create source map files for emitted JavaScript files. */
|
||||
// "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */
|
||||
// "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */
|
||||
"outDir": "build", /* Specify an output folder for all emitted files. */
|
||||
// "removeComments": true, /* Disable emitting comments. */
|
||||
// "noEmit": true, /* Disable emitting files from a compilation. */
|
||||
// "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */
|
||||
// "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types. */
|
||||
// "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */
|
||||
// "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */
|
||||
// "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
|
||||
// "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */
|
||||
// "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */
|
||||
// "newLine": "crlf", /* Set the newline character for emitting files. */
|
||||
// "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */
|
||||
// "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */
|
||||
// "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */
|
||||
// "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */
|
||||
// "declarationDir": "./", /* Specify the output directory for generated declaration files. */
|
||||
// "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */
|
||||
|
||||
/* Interop Constraints */
|
||||
// "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */
|
||||
// "verbatimModuleSyntax": true, /* Do not transform or elide any imports or exports not marked as type-only, ensuring they are written in the output file's format based on the 'module' setting. */
|
||||
// "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */
|
||||
"esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */
|
||||
// "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */
|
||||
"forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */
|
||||
|
||||
/* Type Checking */
|
||||
"strict": true, /* Enable all strict type-checking options. */
|
||||
"noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */
|
||||
"strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */
|
||||
"strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */
|
||||
"strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */
|
||||
"strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */
|
||||
"noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */
|
||||
"useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */
|
||||
"alwaysStrict": true, /* Ensure 'use strict' is always emitted. */
|
||||
"noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */
|
||||
"noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */
|
||||
"exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */
|
||||
"noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */
|
||||
"noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */
|
||||
"noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */
|
||||
"noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */
|
||||
"noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */
|
||||
"allowUnusedLabels": false, /* Disable error reporting for unused labels. */
|
||||
"allowUnreachableCode": false, /* Disable error reporting for unreachable code. */
|
||||
"erasableSyntaxOnly": true, /* Require all TypeScript code to be erasable. */
|
||||
|
||||
/* Completeness */
|
||||
// "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */
|
||||
"skipLibCheck": true /* Skip type checking all .d.ts files. */
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
Reference in New Issue
Block a user