├── .gitignore ├── .prettierignore ├── .prettierrc.json ├── .vscode └── extensions.json ├── LICENSE ├── README.md ├── eslint.config.js ├── package.json ├── packages ├── tweeter │ ├── package.json │ ├── src │ │ ├── index.ts │ │ └── twitter.ts │ └── tsconfig.json └── userscript │ ├── babel.config.json │ ├── meta.json │ ├── package.json │ ├── rollup.config.ts │ ├── src │ ├── config.ts │ ├── gmConfig.d.ts │ ├── main.ts │ ├── sites │ │ ├── feedback.ts │ │ ├── help.ts │ │ ├── minecraft-net.ts │ │ └── zendesk.ts │ ├── types.ts │ └── utils │ │ ├── articleTemplate.ts │ │ ├── autoTranslation.ts │ │ ├── consts.ts │ │ └── converter.ts │ └── tsconfig.json ├── pnpm-lock.yaml ├── pnpm-workspace.yaml ├── tsconfig.base.json └── tsconfig.eslint.json /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | dist 4 | out 5 | config.json 6 | .rollup.cache/ 7 | *.tsbuildinfo 8 | .vscode/ 9 | 10 | # local env files 11 | .env.local 12 | .env.*.local 13 | 14 | # Log files 15 | npm-debug.log* 16 | yarn-debug.log* 17 | yarn-error.log* 18 | lerna-debug.log* 19 | 20 | # pnpm 21 | package-lock.json 22 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | gmConfig.d.ts 2 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 80, 3 | "trailingComma": "es5", 4 | "tabWidth": 2, 5 | "useTabs": false, 6 | "semi": false, 7 | "singleQuote": true 8 | } 9 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "arcanis.vscode-zipfs", 4 | "dbaeumer.vscode-eslint", 5 | "esbenp.prettier-vscode" 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Creative Commons Legal Code 2 | 3 | CC0 1.0 Universal 4 | 5 | CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE 6 | LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN 7 | ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS 8 | INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES 9 | REGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS 10 | PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM 11 | THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED 12 | HEREUNDER. 13 | 14 | Statement of Purpose 15 | 16 | The laws of most jurisdictions throughout the world automatically confer 17 | exclusive Copyright and Related Rights (defined below) upon the creator 18 | and subsequent owner(s) (each and all, an "owner") of an original work of 19 | authorship and/or a database (each, a "Work"). 20 | 21 | Certain owners wish to permanently relinquish those rights to a Work for 22 | the purpose of contributing to a commons of creative, cultural and 23 | scientific works ("Commons") that the public can reliably and without fear 24 | of later claims of infringement build upon, modify, incorporate in other 25 | works, reuse and redistribute as freely as possible in any form whatsoever 26 | and for any purposes, including without limitation commercial purposes. 27 | These owners may contribute to the Commons to promote the ideal of a free 28 | culture and the further production of creative, cultural and scientific 29 | works, or to gain reputation or greater distribution for their Work in 30 | part through the use and efforts of others. 31 | 32 | For these and/or other purposes and motivations, and without any 33 | expectation of additional consideration or compensation, the person 34 | associating CC0 with a Work (the "Affirmer"), to the extent that he or she 35 | is an owner of Copyright and Related Rights in the Work, voluntarily 36 | elects to apply CC0 to the Work and publicly distribute the Work under its 37 | terms, with knowledge of his or her Copyright and Related Rights in the 38 | Work and the meaning and intended legal effect of CC0 on those rights. 39 | 40 | 1. Copyright and Related Rights. A Work made available under CC0 may be 41 | protected by copyright and related or neighboring rights ("Copyright and 42 | Related Rights"). Copyright and Related Rights include, but are not 43 | limited to, the following: 44 | 45 | i. the right to reproduce, adapt, distribute, perform, display, 46 | communicate, and translate a Work; 47 | ii. moral rights retained by the original author(s) and/or performer(s); 48 | iii. publicity and privacy rights pertaining to a person's image or 49 | likeness depicted in a Work; 50 | iv. rights protecting against unfair competition in regards to a Work, 51 | subject to the limitations in paragraph 4(a), below; 52 | v. rights protecting the extraction, dissemination, use and reuse of data 53 | in a Work; 54 | vi. database rights (such as those arising under Directive 96/9/EC of the 55 | European Parliament and of the Council of 11 March 1996 on the legal 56 | protection of databases, and under any national implementation 57 | thereof, including any amended or successor version of such 58 | directive); and 59 | vii. other similar, equivalent or corresponding rights throughout the 60 | world based on applicable law or treaty, and any national 61 | implementations thereof. 62 | 63 | 2. Waiver. To the greatest extent permitted by, but not in contravention 64 | of, applicable law, Affirmer hereby overtly, fully, permanently, 65 | irrevocably and unconditionally waives, abandons, and surrenders all of 66 | Affirmer's Copyright and Related Rights and associated claims and causes 67 | of action, whether now known or unknown (including existing as well as 68 | future claims and causes of action), in the Work (i) in all territories 69 | worldwide, (ii) for the maximum duration provided by applicable law or 70 | treaty (including future time extensions), (iii) in any current or future 71 | medium and for any number of copies, and (iv) for any purpose whatsoever, 72 | including without limitation commercial, advertising or promotional 73 | purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each 74 | member of the public at large and to the detriment of Affirmer's heirs and 75 | successors, fully intending that such Waiver shall not be subject to 76 | revocation, rescission, cancellation, termination, or any other legal or 77 | equitable action to disrupt the quiet enjoyment of the Work by the public 78 | as contemplated by Affirmer's express Statement of Purpose. 79 | 80 | 3. Public License Fallback. Should any part of the Waiver for any reason 81 | be judged legally invalid or ineffective under applicable law, then the 82 | Waiver shall be preserved to the maximum extent permitted taking into 83 | account Affirmer's express Statement of Purpose. In addition, to the 84 | extent the Waiver is so judged Affirmer hereby grants to each affected 85 | person a royalty-free, non transferable, non sublicensable, non exclusive, 86 | irrevocable and unconditional license to exercise Affirmer's Copyright and 87 | Related Rights in the Work (i) in all territories worldwide, (ii) for the 88 | maximum duration provided by applicable law or treaty (including future 89 | time extensions), (iii) in any current or future medium and for any number 90 | of copies, and (iv) for any purpose whatsoever, including without 91 | limitation commercial, advertising or promotional purposes (the 92 | "License"). The License shall be deemed effective as of the date CC0 was 93 | applied by Affirmer to the Work. Should any part of the License for any 94 | reason be judged legally invalid or ineffective under applicable law, such 95 | partial invalidity or ineffectiveness shall not invalidate the remainder 96 | of the License, and in such case Affirmer hereby affirms that he or she 97 | will not (i) exercise any of his or her remaining Copyright and Related 98 | Rights in the Work or (ii) assert any associated claims and causes of 99 | action with respect to the Work, in either case contrary to Affirmer's 100 | express Statement of Purpose. 101 | 102 | 4. Limitations and Disclaimers. 103 | 104 | a. No trademark or patent rights held by Affirmer are waived, abandoned, 105 | surrendered, licensed or otherwise affected by this document. 106 | b. Affirmer offers the Work as-is and makes no representations or 107 | warranties of any kind concerning the Work, express, implied, 108 | statutory or otherwise, including without limitation warranties of 109 | title, merchantability, fitness for a particular purpose, non 110 | infringement, or the absence of latent or other defects, accuracy, or 111 | the present or absence of errors, whether or not discoverable, all to 112 | the greatest extent permissible under applicable law. 113 | c. Affirmer disclaims responsibility for clearing rights of other persons 114 | that may apply to the Work or any use thereof, including without 115 | limitation any person's Copyright and Related Rights in the Work. 116 | Further, Affirmer disclaims responsibility for obtaining any necessary 117 | consents, permissions or other rights required for any use of the 118 | Work. 119 | d. Affirmer understands and acknowledges that Creative Commons is not a 120 | party to this document and has no duty or obligation with respect to 121 | this CC0 or use of the Work. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # spxx 2 | 3 | [![Build Status](https://travis-ci.com/SPGoding/spx.svg?branch=master)](https://travis-ci.com/SPGoding/spx) 4 | ![GitHub Top Language](https://img.shields.io/github/languages/top/SPGoding/spx.svg) 5 | ![License](https://img.shields.io/github/license/SPGoding/spx.svg) 6 | 7 | The new and rewritten version of [spx](https://github.com/SPGoding/spx), the ultimate MCZWLT newslord utility. 8 | 9 | ## Components 10 | 11 | ### SPXX User Script™ 12 | 13 | Adds a "Copy Markdown" button to Minecraft.net, feedback.minecraft.net and help.minecraft.net articles and Tweets, 14 | which sets the [Markdown][markdown] representation of this blog article to your clipboard. 15 | 16 | You can use browser extensions like [Tampermonkey][tampermonkey] to install this script from URL: `https://cdn.jsdelivr.net/npm/@spxx/userscript/dist/bundle.user.js` 17 | 18 | ### SPXX Web Dashboard™ 19 | 20 | Replacing the SPX Discord Bot™, the SPXX Web Dashboard™ Provides means for a selection of trusted individuals to translate the summaries of _Minecraft: Java Edition_ bugs. Also, it provides a list of Minecraft.net blogs for translators to navigate. 21 | 22 | Translations done in the (SPXFellow-Hosted™ SPXX Web Dashboard™)™ is not yet accessible at [https://spx.spgoding.com/bugs][bugs], and 23 | will be utilized by the SPXX User Script™ to auto translate the "Fixed bugs" section in _Minecraft: Java Edition_. 24 | 25 | ## Credits 26 | 27 | - [SPGoding](https://github.com/SPGoding) - maintained the OG spx. 28 | - [RicoloveFeng](https://github.com/RicoloveFeng) - maintains [minecraft.net-translations](https://github.com/RicoloveFeng/minecraft.net-translations/blob/master/rawtable.csv). 29 | 30 | ## Contributing 31 | 32 | Development environment: [Node.js LTS][node] and [Yarn][yarn] 33 | 34 | - `yarn` to install dependencies. 35 | - `yarn run build` to compile the TypeScript code. 36 | - `yarn run start` to start the compiled SPXX Web Dashboard™. 37 | - `./out/user_script.js` is the compiled SPXX User Script™. 38 | 39 | [markdown]: https://en.wikipedia.org/wiki/Markdown 40 | [bugs]: https://spx.spgoding.com/bugs 41 | [node]: https://nodejs.org/ 42 | [yarn]: https://yarnpkg.com/ 43 | [tampermonkey]: https://www.tampermonkey.net 44 | [user-script]: https://spx.spgoding.com/user-script 45 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import eslint from '@eslint/js' 2 | import tseslint from 'typescript-eslint' 3 | import globals from 'globals' 4 | 5 | export default [ 6 | { 7 | ignores: [ 8 | '**/node_modules/**/*', 9 | '**/dist/**/*', 10 | '**/gmConfig.d.ts', 11 | '**/.rollup.cache/**/*', 12 | ], 13 | }, 14 | ...tseslint.config(eslint.configs.recommended, tseslint.configs.recommended, { 15 | rules: { 16 | 'eol-last': 'error', 17 | 'prefer-const': 'error', 18 | 'quote-props': ['error', 'as-needed'], 19 | '@typescript-eslint/no-non-null-assertion': 'off', 20 | '@typescript-eslint/explicit-module-boundary-types': 'off', 21 | '@typescript-eslint/no-unused-vars': [ 22 | 'error', 23 | { 24 | args: 'all', 25 | argsIgnorePattern: '^_', 26 | caughtErrors: 'all', 27 | caughtErrorsIgnorePattern: '^_', 28 | destructuredArrayIgnorePattern: '^_', 29 | varsIgnorePattern: '^_', 30 | ignoreRestSiblings: true, 31 | }, 32 | ], 33 | }, 34 | languageOptions: { 35 | globals: { 36 | ...globals.browser, 37 | ...globals.node, 38 | ...globals.greasemonkey, 39 | }, 40 | }, 41 | }), 42 | ] 43 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "spxx-monorepo", 3 | "version": "0.0.1", 4 | "description": "Monorepo of the new and rewritten version of spx, the ultimate MCBBS newslord utility.", 5 | "type": "module", 6 | "repository": "https://github.com/SPXFellow/spxx.git", 7 | "bugs": "https://github.com/SPXFellow/spx/issues", 8 | "homepage": "https://github.com/SPXFellow/spx#readme", 9 | "author": "SPXFellow", 10 | "license": "CC0-1.0", 11 | "scripts": { 12 | "format": "prettier --write .", 13 | "lint": "eslint ." 14 | }, 15 | "devDependencies": { 16 | "@eslint/eslintrc": "^3.2.0", 17 | "@eslint/js": "^9.19.0", 18 | "eslint": "^9.19.0", 19 | "globals": "^15.14.0", 20 | "prettier": "^3.0.3", 21 | "typescript": "^5.2.2", 22 | "typescript-eslint": "^8.23.0" 23 | }, 24 | "private": true, 25 | "packageManager": "pnpm@9.15.4+sha512.b2dc20e2fc72b3e18848459b37359a32064663e5627a51e4c74b2c29dd8e8e0491483c3abb40789cfd578bf362fb6ba8261b05f0387d76792ed6e23ea3b1b6a0" 26 | } 27 | -------------------------------------------------------------------------------- /packages/tweeter/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@spxx/tweeter", 3 | "version": "0.1.0", 4 | "main": "out/index.js", 5 | "author": "SPXFellow", 6 | "license": "CC0-1.0", 7 | "private": true, 8 | "scripts": { 9 | "build": "tsc -b" 10 | }, 11 | "dependencies": { 12 | "express": "^4.18.2", 13 | "fs-extra": "^11.1.1", 14 | "koa": "^2.14.2", 15 | "twitter-api-sdk": "^1.2.1" 16 | }, 17 | "devDependencies": { 18 | "@types/express": "^4.17.20", 19 | "@types/fs-extra": "^11.0.3", 20 | "@types/koa": "^2.13.10", 21 | "@types/node": "^20.8.9", 22 | "@types/node-fetch": "^2.6.7", 23 | "typescript": "^5.2.2" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /packages/tweeter/src/index.ts: -------------------------------------------------------------------------------- 1 | import express from 'express' 2 | import path from 'path' 3 | import fs from 'fs-extra' 4 | import { TwitterConfig, getTweetMarkdown } from './twitter.js' 5 | 6 | const configPath = path.join(__dirname, './config.json') 7 | let httpPort: number | undefined 8 | let ip: string | undefined 9 | let twitter: TwitterConfig | undefined 10 | 11 | if (fs.existsSync(configPath)) { 12 | const config = fs.readJsonSync(configPath) 13 | ip = config.ip 14 | httpPort = config.httpPort 15 | twitter = config.twitter 16 | if (!ip || !httpPort || !twitter) { 17 | throw "Expected 'ip', 'httpPort', 'twitter' in './config.json'." 18 | } 19 | } else { 20 | ip = 'localhost' 21 | httpPort = 80 22 | fs.writeJsonSync( 23 | configPath, 24 | { ip, httpPort, twitter: { bearer_token: '' } }, 25 | { encoding: 'utf8' } 26 | ) 27 | throw 'Please complete the config file.' 28 | } 29 | 30 | const app = express().get('/tweet/:tweetId', async (req, res) => { 31 | const mode: 'light' | 'dark' = 32 | req.query.mode === 'light' || req.query.mode === 'dark' 33 | ? (req.query.mode as 'light' | 'dark') 34 | : 'light' 35 | console.log(`Tweet: ${req.params.tweetId}, ${mode}`) 36 | const bbcode = await getTweetMarkdown(twitter!, req.params.tweetId, mode) 37 | res.send(bbcode) 38 | }) 39 | 40 | app 41 | .listen(httpPort, () => { 42 | console.info( 43 | `HTTP server is running at ${ip} (locally listening ${httpPort})` 44 | ) 45 | }) 46 | .on('error', (e) => { 47 | console.error('[HttpServer] ', e.message) 48 | }) 49 | -------------------------------------------------------------------------------- /packages/tweeter/src/twitter.ts: -------------------------------------------------------------------------------- 1 | import { Client } from 'twitter-api-sdk' 2 | 3 | export interface TwitterConfig { 4 | bearer_token: string 5 | } 6 | 7 | export async function getTweetMarkdown( 8 | config: TwitterConfig, 9 | tweetId: string, 10 | mode: 'light' | 'dark' 11 | ): Promise { 12 | if (config === undefined) { 13 | return 'twitter not configured' 14 | } else if (tweetId === undefined) { 15 | return 'no tweet id' 16 | } 17 | return ( 18 | (await getTweet(config.bearer_token, mode, tweetId)) || 19 | 'Error getting tweet' 20 | ) 21 | } 22 | 23 | const ProfilePictures = new Map([ 24 | [ 25 | 'Mojang', 26 | 'https://attachment.mcbbs.net/data/myattachment/forum/202105/18/124525b5b85bb8ob8t8o0b.jpg', 27 | ], 28 | [ 29 | 'MojangSupport', 30 | 'https://attachment.mcbbs.net/data/myattachment/forum/202105/18/124525b5b85bb8ob8t8o0b.jpg', 31 | ], 32 | [ 33 | 'MojangStatus', 34 | 'https://attachment.mcbbs.net/data/myattachment/forum/202105/18/124525b5b85bb8ob8t8o0b.jpg', 35 | ], 36 | [ 37 | 'Minecraft', 38 | 'https://attachment.mcbbs.net/data/myattachment/forum/202105/18/124524kfu7hzreleueuexh.jpg', 39 | ], 40 | [ 41 | 'henrikkniberg', 42 | 'https://attachment.mcbbs.net/data/myattachment/forum/202105/18/124519x0r898zl6gc8gna8.jpg', 43 | ], 44 | [ 45 | '_LadyAgnes', 46 | 'https://attachment.mcbbs.net/data/myattachment/forum/202105/18/124515qnwcdnz82vyz9ezs.png', 47 | ], 48 | [ 49 | 'kingbdogz', 50 | 'https://attachment.mcbbs.net/data/myattachment/forum/202105/18/124523da4of54hl7e3fchn.jpg', 51 | ], 52 | [ 53 | 'JasperBoerstra', 54 | 'https://attachment.mcbbs.net/data/myattachment/forum/202105/18/124522uk3hbr2gx62pbrfh.jpg', 55 | ], 56 | [ 57 | 'adrian_ivl', 58 | 'https://attachment.mcbbs.net/data/myattachment/forum/202105/18/124513jppdcsu8lsxllxll.jpg', 59 | ], 60 | [ 61 | 'slicedlime', 62 | 'https://attachment.mcbbs.net/data/myattachment/forum/202105/18/124528na53pu1444w1pdys.jpg', 63 | ], 64 | [ 65 | 'Cojomax99', 66 | 'https://attachment.mcbbs.net/data/myattachment/forum/202105/18/124516jgwgrzgerr11g9kn.png', 67 | ], 68 | [ 69 | 'Mojang_Ined', 70 | 'https://attachment.mcbbs.net/data/myattachment/forum/202105/18/124520dpqpa0fufu0fq0l1.jpg', 71 | ], 72 | [ 73 | 'SeargeDP', 74 | 'https://attachment.mcbbs.net/data/myattachment/forum/202105/18/124527syfrwsstbvxf8jf0.png', 75 | ], 76 | [ 77 | 'Dinnerbone', 78 | 'https://attachment.mcbbs.net/data/myattachment/forum/202105/18/124517k1n33zuxaumkakam.jpg', 79 | ], 80 | [ 81 | 'Marc_IRL', 82 | 'https://attachment.mcbbs.net/data/myattachment/forum/202105/28/104919xl2ac5dihxlqxxdf.jpg', 83 | ], 84 | [ 85 | 'Mega_Spud', 86 | 'https://attachment.mcbbs.net/data/myattachment/forum/202107/07/230046homkfqlhwvkfqkbh.jpg', 87 | ], 88 | ]) 89 | 90 | export async function getTweet( 91 | token: string, 92 | mode: 'dark' | 'light', 93 | tweetId: string, 94 | translator = '???' 95 | ) { 96 | const client = new Client(token, { 97 | base_url: 'https://api.twitter.com', 98 | }) 99 | 100 | // try { 101 | console.log('sending') 102 | const result = await client.tweets.findTweetById(`${tweetId}`, { 103 | expansions: ['attachments.media_keys', 'author_id'], 104 | 'tweet.fields': [ 105 | 'attachments', 106 | 'author_id', 107 | 'created_at', 108 | 'entities', 109 | 'lang', 110 | 'source', 111 | 'text', 112 | ], 113 | 'user.fields': ['name', 'username'], 114 | }) 115 | const author = result.includes?.users?.find( 116 | (u) => u.id === result?.data?.author_id 117 | )! 118 | const bbcode = getTweetBbcode({ 119 | date: new Date(result.data?.created_at!), 120 | lang: result.data?.lang!, 121 | mode, 122 | source: result.data?.source!, 123 | text: result.data?.text!, 124 | translator, 125 | tweetLink: `https://twitter.com/${author.username}/status/${tweetId}`, 126 | urls: result.data?.entities?.urls ?? [], 127 | userName: author.name, 128 | userTag: author.username, 129 | }) 130 | console.log(bbcode) 131 | return bbcode 132 | // } catch (e) { 133 | // // https://stackoverflow.com/a/69028217 134 | // if (e instanceof Error) { 135 | // console.error(`❌ 与 Twitter API 交互出错:\n\`\`\`\n${e?.toString().slice(0, 127)}\n\`\`\``) 136 | // throw new Error(`❌ 与 Twitter API 交互出错:\n\`\`\`\n${e?.toString().slice(0, 127)}\n\`\`\``) 137 | // } 138 | // } 139 | } 140 | 141 | function getTweetBbcode({ 142 | date, 143 | lang, 144 | mode, 145 | source, 146 | text, 147 | translator, 148 | tweetLink, 149 | urls, 150 | userName, 151 | userTag, 152 | }: { 153 | date: Date 154 | lang: string 155 | mode: 'dark' | 'light' 156 | source: string 157 | text: string 158 | translator: string 159 | tweetLink: string 160 | urls: { 161 | start: number 162 | end: number 163 | url: string 164 | expanded_url?: string 165 | display_url?: string 166 | }[] 167 | userName: string 168 | userTag: string 169 | }): string { 170 | const attributeColor = '#5B7083' 171 | const linkColor = '#1B95E0' 172 | const backgroundColor = mode === 'dark' ? '#000000' : '#FFFFFF' 173 | const foregroundColor = mode === 'dark' ? '#D9D9D9' : '#0F1419' 174 | const dateString = `${date.toLocaleTimeString( 175 | 'zh-cn' 176 | )} · ${date.toLocaleDateString('zh-cn')} · ${source} · SPX` 177 | let skippedIndex = 0 178 | let content = text 179 | for (const url of urls) { 180 | const urlMarkdown = `[url=${url.expanded_url}][color=${linkColor}]${url.display_url}[/color][/url]` 181 | content = 182 | content.slice(0, skippedIndex + url.start - 1) + 183 | urlMarkdown + 184 | content.slice(skippedIndex + url.end) 185 | skippedIndex += urlMarkdown.length - (url.end - url.start) 186 | } 187 | const res = `[align=center][table=560,${backgroundColor}] 188 | [tr][td][font=-apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Helvetica, Arial, sans-serif][indent] 189 | [float=left][img=44,44]${ 190 | ProfilePictures.get(userTag) ?? '【TODO:头像】' 191 | }[/img][/float][size=15px][b][color=${foregroundColor}]${userName}[/color][/b] 192 | [color=${attributeColor}]@${userTag}[/color][/size] 193 | 194 | [color=${foregroundColor}][size=23px]${content}[/size] 195 | [size=15px]由 ${translator} 翻译自${ 196 | lang.startsWith('en') ? '英语' : ` ${lang}` 197 | }[/size] 198 | [size=23px]【插入:译文】[/size][/color][/indent][align=center][img=451,254]【TODO:配图】[/img][/align][indent][size=15px][url=${tweetLink}][color=${attributeColor}]${dateString}[/color][/url][/size][/indent][/font] 199 | [/td][/tr] 200 | [/table][/align]` 201 | console.log(res) 202 | return res 203 | } 204 | -------------------------------------------------------------------------------- /packages/tweeter/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["./src/**/*.ts"], 3 | "compilerOptions": { 4 | "allowSyntheticDefaultImports": true, 5 | "downlevelIteration": true, 6 | "outDir": "dist/", 7 | "resolveJsonModule": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "lib": ["esnext", "dom"], 10 | "target": "esnext", 11 | "module": "commonjs", 12 | "moduleResolution": "node", 13 | "strict": true, 14 | "skipLibCheck": true, 15 | "esModuleInterop": true 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /packages/userscript/babel.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["@babel/preset-typescript"] 3 | } 4 | -------------------------------------------------------------------------------- /packages/userscript/meta.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "SPXX", 3 | "author": "SPGoding & SPX Fellow", 4 | "connect": [ 5 | "feedback.minecraft.com", 6 | "help.minecraft.net", 7 | "raw.githubusercontent.com", 8 | "bugs.guangyaostore.com", 9 | "spxx.bugjump.net", 10 | "*" 11 | ], 12 | "namespace": "npmjs.com/package/@spxx/userscript", 13 | "description": "Minecraft.net blog article to Markdown converter, rewritten", 14 | "downloadURL": "https://fastly.jsdelivr.net/npm/@spxx/userscript/dist/bundle.user.js", 15 | "updateURL": "https://fastly.jsdelivr.net/npm/@spxx/userscript/dist/bundle.user.js", 16 | "homepage": "https://github.com/SPXFellow/spxx", 17 | "match": [ 18 | "https://www.minecraft.net/en-us/article/*", 19 | "https://www.minecraft.net/zh-hans/article/*", 20 | "https://twitter.com/*/status/*", 21 | "https://mobile.twitter.com/*/status/*", 22 | "https://feedback.minecraft.net/hc/en-us/articles/*", 23 | "https://help.minecraft.net/hc/en-us/articles/*" 24 | ], 25 | "require": ["https://openuserjs.org/src/libs/sizzle/GM_config.js"], 26 | "grant": [ 27 | "GM.setClipboard", 28 | "GM.xmlHttpRequest", 29 | "GM.getValue", 30 | "GM.setValue", 31 | "GM.registerMenuCommand" 32 | ] 33 | } 34 | -------------------------------------------------------------------------------- /packages/userscript/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@spxx/userscript", 3 | "version": "3.0.1", 4 | "description": "The userscript of spxx, the ultimate MCBBS newslord utility.", 5 | "main": "dist/bundle.user.js", 6 | "type": "module", 7 | "author": "SPXFellow", 8 | "license": "CC0-1.0", 9 | "scripts": { 10 | "build": "rollup --config rollup.config.ts --configPlugin @rollup/plugin-typescript --configImportAttributesKey with", 11 | "prepack": "pnpm build", 12 | "watch": "rollup -w --config rollup.config.ts --configPlugin @rollup/plugin-typescript --configImportAttributesKey with" 13 | }, 14 | "devDependencies": { 15 | "@babel/core": "^7.23.2", 16 | "@babel/preset-env": "^7.23.2", 17 | "@babel/preset-typescript": "^7.23.2", 18 | "@rollup/plugin-babel": "^6.0.4", 19 | "@rollup/plugin-json": "^6.0.1", 20 | "@rollup/plugin-node-resolve": "^16.0.0", 21 | "@rollup/plugin-typescript": "^12.1.2", 22 | "@types/tampermonkey": "^5.0.4", 23 | "eslint": "^9.19.0", 24 | "rollup": "^4.1.4", 25 | "rollup-plugin-userscript-metablock": "^0.4.2", 26 | "tslib": "^2.6.2", 27 | "typescript": "^5.2.2" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /packages/userscript/rollup.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'rollup' 2 | import typescriptPlugin from '@rollup/plugin-typescript' 3 | import babelPluggin from '@rollup/plugin-babel' 4 | import metablock from 'rollup-plugin-userscript-metablock' 5 | import nodeResolve from '@rollup/plugin-node-resolve' 6 | import jsonPlugin from '@rollup/plugin-json' 7 | import * as pkg from './package.json' with { type: 'json' } 8 | 9 | export default defineConfig({ 10 | input: 'src/main.ts', 11 | output: { 12 | file: 'dist/bundle.user.js', 13 | format: 'iife', 14 | sourcemap: true, 15 | importAttributesKey: 'with', 16 | }, 17 | plugins: [ 18 | typescriptPlugin({ 19 | outputToFilesystem: true, 20 | }), 21 | babelPluggin({ 22 | babelHelpers: 'bundled', 23 | extensions: ['.js', '.jsx', '.es6', '.es', '.mjs', '.ts', '.tsx'], 24 | }), 25 | metablock({ 26 | file: './meta.json', 27 | override: { 28 | version: pkg.version, 29 | }, 30 | }), 31 | nodeResolve(), 32 | jsonPlugin(), 33 | ], 34 | }) 35 | -------------------------------------------------------------------------------- /packages/userscript/src/config.ts: -------------------------------------------------------------------------------- 1 | interface BugCenterConfig { 2 | translation: string 3 | translator: string 4 | color: string 5 | } 6 | 7 | interface Config { 8 | translator: string 9 | bugCenter: BugCenterConfig 10 | } 11 | 12 | let config: Config 13 | 14 | export const initConfig = new Promise((resolve) => { 15 | GM_config.init({ 16 | id: 'spxx', 17 | title: 'SPXX Userscript', 18 | fields: { 19 | translator: { 20 | label: 'Translator', 21 | type: 'text', 22 | default: 'SPXX User', 23 | }, 24 | bugSource: { 25 | label: 'Translation Source', 26 | type: 'select', 27 | options: ['Guangyao', 'Github', 'Custom'], 28 | default: 'Guangyao', 29 | }, 30 | bugCenterTranslation: { 31 | label: 'Custom translation source', 32 | type: 'text', 33 | default: 'https://bugs.guangyaostore.com/translations', 34 | }, 35 | bugCenterTranslator: { 36 | label: 'Custom translator source', 37 | type: 'text', 38 | default: 'https://bugs.guangyaostore.com/translator', 39 | }, 40 | bugCenterColor: { 41 | label: 'Custom color source', 42 | type: 'text', 43 | default: 'https://bugs.guangyaostore.com/color', 44 | }, 45 | }, 46 | events: { 47 | init: () => { 48 | fillExport() 49 | resolve(undefined) 50 | }, 51 | }, 52 | }) 53 | }) 54 | 55 | GM.registerMenuCommand('Edit Configuration', () => GM_config.open()) 56 | 57 | function fillExport() { 58 | const src = GM_config.get('bugSource') as string 59 | let tr = '' 60 | let tor = '' 61 | let c = '' 62 | if (src == 'Guangyao') { 63 | console.log('[SPXX] Using Guangyao bug center') 64 | tr = 'https://bugs.guangyaostore.com/translations' 65 | tor = 'https://bugs.guangyaostore.com/translator' 66 | c = 'https://bugs.guangyaostore.com/color' 67 | } else if (src == 'Github') { 68 | console.log('[SPXX] Using Github bug center') 69 | tr = 70 | 'https://raw.githubusercontent.com/SPXFellow/spxx-translation-database/crowdin/zh-CN/zh_CN.json' 71 | tor = 72 | 'https://raw.githubusercontent.com/SPXFellow/spxx-translation-database/master/translator.json' 73 | c = 74 | 'https://raw.githubusercontent.com/SPXFellow/spxx-translation-database/master/color.json' 75 | } else { 76 | console.log('[SPXX] Using custom bug center') 77 | tr = GM_config.get('bugCenterTranslation') as string 78 | tor = GM_config.get('bugCenterTranslator') as string 79 | c = GM_config.get('bugCenterColor') as string 80 | } 81 | 82 | config = { 83 | translator: GM_config.get('translator') as string, 84 | bugCenter: { 85 | translation: tr, 86 | translator: tor, 87 | color: c, 88 | }, 89 | } 90 | } 91 | 92 | export { config as default } 93 | -------------------------------------------------------------------------------- /packages/userscript/src/gmConfig.d.ts: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2009+, GM_config Contributors (https://github.com/sizzlemctwizzle/GM_config) 3 | 4 | GM_config Collaborators/Contributors: 5 | Mike Medley 6 | Joe Simmons 7 | Izzy Soft 8 | Marti Martz 9 | Adam Thompson-Sharpe 10 | 11 | GM_config is distributed under the terms of the GNU Lesser General Public License. 12 | 13 | GM_config is free software: you can redistribute it and/or modify 14 | it under the terms of the GNU Lesser General Public License as published by 15 | the Free Software Foundation, either version 3 of the License, or 16 | (at your option) any later version. 17 | 18 | This program is distributed in the hope that it will be useful, 19 | but WITHOUT ANY WARRANTY; without even the implied warranty of 20 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 21 | GNU Lesser General Public License for more details. 22 | 23 | You should have received a copy of the GNU Lesser General Public License 24 | along with this program. If not, see . 25 | */ 26 | 27 | // Minimum TypeScript Version: 2.8 28 | 29 | type FieldValue = string | number | boolean; 30 | /** Valid types for Field `type` property */ 31 | type FieldTypes = 32 | | 'text' 33 | | 'textarea' 34 | | 'button' 35 | | 'radio' 36 | | 'select' 37 | | 'checkbox' 38 | | 'unsigned int' 39 | | 'unsigned integer' 40 | | 'int' 41 | | 'integer' 42 | | 'float' 43 | | 'number' 44 | | 'hidden'; 45 | 46 | /** Init options where no custom types are defined */ 47 | interface InitOptionsNoCustom { 48 | /** Used for this instance of GM_config */ 49 | id: string; 50 | /** Label the opened config window */ 51 | title?: string | HTMLElement; 52 | fields: Record; 53 | /** Optional styling to apply to the menu */ 54 | css?: string; 55 | /** Element to use for the config panel */ 56 | frame?: HTMLElement; 57 | 58 | /** Handlers for different events */ 59 | events?: { 60 | init?: GM_configStruct['onInit']; 61 | open?: GM_configStruct['onOpen']; 62 | save?: GM_configStruct['onSave']; 63 | close?: GM_configStruct['onClose']; 64 | reset?: GM_configStruct['onReset']; 65 | }; 66 | } 67 | 68 | /** Init options where custom types are defined */ 69 | interface InitOptionsCustom extends Omit { 70 | fields: Record>; 71 | /** Custom fields */ 72 | types: { [type in CustomTypes]: CustomType }; 73 | } 74 | 75 | /** Init options where the types key is only required if custom types are used */ 76 | type InitOptions = InitOptionsNoCustom | InitOptionsCustom; 77 | 78 | interface Field { 79 | [key: string]: any; 80 | /** Display label for the field */ 81 | label?: string | HTMLElement; 82 | /** Type of input */ 83 | type: FieldTypes | CustomTypes; 84 | /** Text to show on hover */ 85 | title?: string; 86 | /** Default value for field */ 87 | default?: FieldValue; 88 | save?: boolean; 89 | } 90 | 91 | interface CustomType { 92 | default?: FieldValue | null; 93 | toNode?: GM_configField['toNode']; 94 | toValue?: GM_configField['toValue']; 95 | reset?: GM_configField['reset']; 96 | } 97 | 98 | /* GM_configStruct and related */ 99 | 100 | /** Initialize a GM_configStruct */ 101 | declare function GM_configInit( 102 | config: GM_configStruct, 103 | // tslint:disable-next-line:no-unnecessary-generics 104 | options: InitOptions, 105 | ): void; 106 | 107 | declare function GM_configDefaultValue(type: FieldTypes): FieldValue; 108 | 109 | /** Create multiple GM_config instances */ 110 | declare class GM_configStruct { 111 | constructor(options: InitOptions) 112 | 113 | /** Initialize GM_config */ 114 | // tslint:disable-next-line:no-unnecessary-generics 115 | init(options: InitOptions): void; 116 | 117 | /** Display the config panel */ 118 | open(): void; 119 | /** Close the config panel */ 120 | close(): void; 121 | 122 | /** Directly set the value of a field */ 123 | set(fieldId: string, value: FieldValue): void; 124 | /** 125 | * Get a config value 126 | * @param getLive If true, runs `field.toValue()` instead of just getting `field.value` 127 | */ 128 | get(fieldId: string, getLive?: boolean): FieldValue; 129 | /** Save the current values */ 130 | save(): void; 131 | 132 | read(store?: string): any; 133 | 134 | write(store?: string, obj?: any): any; 135 | 136 | /** 137 | * 138 | * @param args If only one arg is passed, argument is passed to `document.createTextNode`. 139 | * With any other amount, args[0] is passed to `document.createElement` and the second arg 140 | * has something to do with event listeners? 141 | * 142 | * @todo Improve types based on 143 | * 144 | */ 145 | create(...args: [string] | [string, any] | []): HTMLElement; 146 | 147 | center(): void; 148 | 149 | remove(el: HTMLElement): void; 150 | 151 | /* Computed */ 152 | 153 | /** Whether GreaseMonkey functions are present */ 154 | isGM: boolean; 155 | /** 156 | * Either calls `localStorage.setItem` or `GM_setValue`. 157 | * Shouldn't be directly called 158 | */ 159 | setValue(name: string, value: FieldValue): Promise | void; 160 | /** 161 | * Get a value. Shouldn't be directly called 162 | * 163 | * @param name The name of the value 164 | * @param def The default to return if the value is not defined. 165 | * Only for localStorage fallback 166 | */ 167 | getValue(name: string, def: FieldValue): FieldValue; 168 | 169 | /** Converts a JSON object to a string */ 170 | stringify(obj: any): string; 171 | /** 172 | * Converts a string to a JSON object 173 | * @returns `undefined` if the string was an invalid object, 174 | * otherwise returns the parsed object 175 | */ 176 | parser(jsonString: string): any; 177 | 178 | /** Log a string with multiple fallbacks */ 179 | log(data: string): void; 180 | 181 | /* Created from GM_configInit */ 182 | id: string; 183 | title: string; 184 | css: { 185 | basic: string[]; 186 | basicPrefix: string; 187 | stylish: string; 188 | }; 189 | frame?: HTMLElement; 190 | fields: Record; 191 | onInit?: (this: GM_configStruct) => void; 192 | onOpen?: (this: GM_configStruct, document: Document, window: Window, frame: HTMLElement) => void; 193 | onSave?: (this: GM_configStruct, values: any) => void; 194 | onClose?: (this: GM_configStruct) => void; 195 | onReset?: (this: GM_configStruct) => void; 196 | isOpen: boolean; 197 | } 198 | 199 | /** Default GM_config object */ 200 | declare let GM_config: GM_configStruct; 201 | 202 | /* GM_configField and related */ 203 | 204 | declare class GM_configField { 205 | constructor( 206 | settings: Field, 207 | stored: FieldValue | undefined, 208 | id: string, 209 | customType: CustomType | undefined, 210 | configId: string, 211 | ) 212 | 213 | [key: string]: any; 214 | settings: Field; 215 | id: string; 216 | configId: string; 217 | node: HTMLElement | null; 218 | wrapper: HTMLElement | null; 219 | save: boolean; 220 | /** The stored value */ 221 | value: FieldValue; 222 | default: FieldValue; 223 | 224 | create: GM_configStruct['create']; 225 | 226 | toNode(this: GM_configField, configId?: string): HTMLElement; 227 | 228 | /** Get value from field */ 229 | toValue(this: GM_configField): FieldValue | null; 230 | 231 | reset(this: GM_configField): void; 232 | 233 | remove(el?: HTMLElement): void; 234 | 235 | reload(): void; 236 | 237 | _checkNumberRange(num: number, warn: string): true | null; 238 | } 239 | -------------------------------------------------------------------------------- /packages/userscript/src/main.ts: -------------------------------------------------------------------------------- 1 | import { minecraftNet } from './sites/minecraft-net' 2 | import { feedback } from './sites/feedback' 3 | import { help } from './sites/help' 4 | import './config' 5 | import { initConfig } from './config' 6 | 7 | initConfig.then(() => { 8 | switch (location.host) { 9 | case 'www.minecraft.net': 10 | minecraftNet() 11 | break 12 | /* case 'twitter.com': 13 | case 'moble.twitter.com': 14 | twitter() 15 | break 16 | */ 17 | case 'feedback.minecraft.net': 18 | feedback() 19 | break 20 | case 'help.minecraft.net': 21 | help() 22 | } 23 | }) 24 | -------------------------------------------------------------------------------- /packages/userscript/src/sites/feedback.ts: -------------------------------------------------------------------------------- 1 | import { VersionType } from '../utils/articleTemplate' 2 | import getZendesk from './zendesk' 3 | 4 | export function feedback() { 5 | let versionType = VersionType.Normal 6 | if ( 7 | document.querySelector( 8 | '[title="Beta and Preview Information and Changelogs"]' 9 | ) 10 | ) { 11 | versionType = VersionType.BedrockBeta 12 | } else if (document.querySelector('[title="Release Changelogs"]')) { 13 | versionType = VersionType.BedrockRelease 14 | } 15 | 16 | getZendesk( 17 | (button) => { 18 | document.querySelector('.topNavbar nav')!.append(button) 19 | }, 20 | ' – Minecraft Feedback', 21 | 'article-info', 22 | versionType 23 | ) 24 | } 25 | -------------------------------------------------------------------------------- /packages/userscript/src/sites/help.ts: -------------------------------------------------------------------------------- 1 | import getZendesk from './zendesk' 2 | 3 | export function help() { 4 | getZendesk( 5 | (button: HTMLElement) => { 6 | const nav = document.createElement('nav') 7 | nav.classList.add('my-0') 8 | nav.append(button) 9 | document.querySelector('.topNavbar .d-flex')!.append(nav) 10 | }, 11 | ' – Home', 12 | 'article-body', 13 | null 14 | ) 15 | } 16 | -------------------------------------------------------------------------------- /packages/userscript/src/sites/minecraft-net.ts: -------------------------------------------------------------------------------- 1 | import config from '../config' 2 | import { ColorMap, Context, ResolvedBugs, Translator } from '../types' 3 | import { VersionType, getHeader, getFooter } from '../utils/articleTemplate' 4 | import { spxxVersion } from '../utils/consts' 5 | import { 6 | getBugs, 7 | converters, 8 | getBugsTranslators, 9 | getTranslatorColor, 10 | } from '../utils/converter' 11 | 12 | export async function minecraftNet() { 13 | const url = document.location.toString() 14 | if (url.match(/^https:\/\/www\.minecraft\.net\/(?:[a-z-]+)\/article\//)) { 15 | const button = document.createElement('button') 16 | button.classList.add( 17 | 'mc-MC_Button', 18 | 'MC_Button_Hero', 19 | 'spxx-userscript-ignored' 20 | ) 21 | button.innerText = 'Copy Markdown' 22 | button.onclick = async () => { 23 | button.innerText = 'Processing...' 24 | const markdown = await convertMCArticleToMarkdown(document, url) 25 | GM.setClipboard(markdown, { type: 'text', mimetype: 'text/plain' }) 26 | button.innerText = 'Copied Markdown!' 27 | setTimeout(() => (button.innerText = 'Copy Markdown'), 5_000) 28 | } 29 | 30 | const container = document 31 | .getElementsByClassName('MC_articleHeroA_attribution') 32 | .item(0) as HTMLDivElement 33 | container.append(button) 34 | } 35 | } 36 | 37 | async function convertMCArticleToMarkdown( 38 | html: Document, 39 | articleUrl: string, 40 | translator = config.translator 41 | ) { 42 | const articleType = getArticleType(html) 43 | const versionType = getVersionType(articleUrl) 44 | 45 | let bugs: ResolvedBugs 46 | try { 47 | bugs = await getBugs() 48 | } catch (e) { 49 | bugs = {} 50 | console.error('[convertMCArticleToMarkdown#getBugs]', e) 51 | } 52 | 53 | let bugsTranslators: Translator 54 | try { 55 | bugsTranslators = await getBugsTranslators() 56 | } catch (e) { 57 | bugsTranslators = {} 58 | console.error('[convertMCArticleToMarkdown#getBugs]', e) 59 | } 60 | 61 | let translatorColor: ColorMap 62 | try { 63 | translatorColor = await getTranslatorColor() 64 | } catch (e) { 65 | translatorColor = {} 66 | console.error('[convertMCArticleToMarkdown#getBugs]', e) 67 | } 68 | 69 | const header = getHeader(articleType, versionType) 70 | const title = html.title.split(' | ').slice(0, -1).join(' | ') 71 | 72 | const content = await getContent(html, { 73 | bugs, 74 | bugsTranslators, 75 | translatorColor, 76 | title, 77 | date: null, 78 | translator, 79 | url: articleUrl, 80 | }) 81 | 82 | // Get the server URL if it exists. 83 | const serverUrl = html.querySelector( 84 | `a[href^="https://piston-data.mojang.com/"][href$="/server.jar"]` 85 | ) 86 | 87 | const footer = getFooter( 88 | articleType, 89 | versionType, 90 | serverUrl !== null ? serverUrl.getAttribute('href')! : undefined 91 | ) 92 | 93 | const footerInfo = { 94 | year: 'XXXX', 95 | month: 'XX', 96 | day: 'XX', 97 | author: 'XXXXXX', 98 | } 99 | const ldJsonEle = html.querySelector('script[type="application/ld+json"]') 100 | if (ldJsonEle) { 101 | const ldJson = JSON.parse(ldJsonEle.textContent!) 102 | if (ldJson.datePublished) { 103 | const date = new Date(ldJson.datePublished) 104 | footerInfo.year = date.getFullYear().toString() 105 | footerInfo.month = (date.getMonth() + 1).toString() 106 | footerInfo.day = date.getDate().toString() 107 | } 108 | if (ldJson.author?.name) { 109 | footerInfo.author = ldJson.author.name 110 | } 111 | } 112 | 113 | const ans = `${header}${content}--- 114 | 115 | **【${translator} 译自[官网 ${footerInfo.year} 年 ${footerInfo.month} 月 ${footerInfo.day} 日发布的 ${title}](${articleUrl})】** 116 | 【本文排版借助了:[SPXX Userscript v${spxxVersion}](https://www.mczwlt.net/resource/ilm1b1xr)】${footer}` 117 | 118 | return ans 119 | } 120 | 121 | /** 122 | * Returns the type of the article. 123 | */ 124 | function getArticleType(html: Document): string { 125 | try { 126 | const type = 127 | html.getElementsByClassName('MC_articleHeroA_category')?.[0] 128 | ?.textContent ?? '' 129 | return type.toUpperCase() 130 | } catch (e) { 131 | console.error('[getArticleType]', e) 132 | } 133 | return 'INSIDER' 134 | } 135 | 136 | function getVersionType(url: string): VersionType { 137 | if (url.toLowerCase().includes('pre-release')) { 138 | return VersionType.PreRelease 139 | } else if (url.toLowerCase().includes('release-candidate')) { 140 | return VersionType.ReleaseCandidate 141 | } else if (url.toLowerCase().includes('snapshot')) { 142 | return VersionType.Snapshot 143 | } else if (url.toLowerCase().includes('minecraft-java-edition')) { 144 | return VersionType.Release 145 | } else if (url.toLowerCase().includes('minecraft-beta---preview---')) { 146 | return VersionType.BedrockBeta 147 | } else { 148 | return VersionType.Normal 149 | } 150 | } 151 | 152 | /** 153 | * Get the content of an article as the form of a Markdown string. 154 | * @param html An HTML Document. 155 | */ 156 | async function getContent(html: Document, ctx: Context) { 157 | let ans = '' 158 | for (const rootDiv of html.querySelectorAll( 159 | '.MC_Layout_Article > div > *:not(:nth-last-child(-n + 2))' 160 | )) { 161 | ans += await converters.recursive(rootDiv as HTMLElement, ctx) 162 | } 163 | console.log(ans) 164 | 165 | // Remove 'GET THE SNAPSHOT/PRE-RELEASE/RELEASE-CANDIDATE/RELEASE' for releasing 166 | const index = ans 167 | .toLowerCase() 168 | .search(/#+ get the (pre-release|release|release candidate|snapshot)/) 169 | if (index !== -1) { 170 | console.log(index) 171 | ans = ans.slice(0, index) 172 | } 173 | 174 | return ans 175 | } 176 | -------------------------------------------------------------------------------- /packages/userscript/src/sites/zendesk.ts: -------------------------------------------------------------------------------- 1 | import { VersionType, getHeader, getFooter } from '../utils/articleTemplate' 2 | import { converters } from '../utils/converter' 3 | import { Context } from '../types' 4 | import translate from '../utils/autoTranslation' 5 | import config from '../config' 6 | import { spxxVersion } from '../utils/consts' 7 | 8 | export default function getZendesk( 9 | controlDOM: (button: HTMLElement) => void, 10 | titleSlice: string, 11 | contentClass: string, 12 | versionType: VersionType | null 13 | ) { 14 | const button = document.createElement('a') 15 | button.classList.add('navLink') 16 | button.innerText = 'Copy Markdown' 17 | button.onclick = async () => { 18 | button.innerText = 'Processing...' 19 | const bbcode = await convertZendeskArticleToMarkdown( 20 | document, 21 | location.href, 22 | config.translator, 23 | titleSlice, 24 | contentClass, 25 | versionType 26 | ) 27 | GM.setClipboard(bbcode, { type: 'text', mimetype: 'text/plain' }) 28 | button.innerText = 'Copied Markdown!' 29 | setTimeout(() => (button.innerText = 'Copy Markdown'), 5_000) 30 | } 31 | 32 | controlDOM(button) 33 | } 34 | 35 | async function convertZendeskArticleToMarkdown( 36 | html: Document, 37 | articleUrl: string, 38 | translator = config.translator, 39 | titleSlice: string, 40 | contentClass: string, 41 | versionType: VersionType | null 42 | ) { 43 | const title = html.title.slice(0, html.title.lastIndexOf(titleSlice)) 44 | const ctx: Context = { 45 | bugs: {}, 46 | title: title, 47 | date: null, 48 | translator, 49 | url: articleUrl, 50 | } 51 | const content = await getZendeskContent(html, ctx, contentClass) 52 | const posted = await getZendeskDate(location.href) 53 | const header = versionType ? getHeader('news', versionType) : '' 54 | const footer = versionType ? getFooter('news', versionType) : '' 55 | 56 | const ans = `${header} 57 | # ${translate(`${title}`, ctx, 'headings')}\n\n${content}\n 58 | **【${ctx.translator} 译自[${ 59 | ctx.url.match(/https:\/\/(.*?)\//)?.[1] ?? 'unknown' 60 | } ${posted.year} 年 ${posted.month} 月 ${posted.day} 日发布的 ${ 61 | ctx.title 62 | }](${ctx.url})】** 63 | 【本文排版借助了:[SPXX Userscript v${spxxVersion}](https://www.mczwlt.net/resource/ilm1b1xr)】${footer}` 64 | 65 | return ans 66 | } 67 | 68 | async function getZendeskContent( 69 | html: Document, 70 | ctx: Context, 71 | contentClass: string 72 | ) { 73 | const rootSection = html.getElementsByClassName( 74 | contentClass 75 | )[0] as HTMLElement // Yep, this is the only difference. 76 | let ans = await converters.recursive(rootSection, ctx) 77 | 78 | // Add spaces between texts and '[x'. 79 | ans = ans.replace(/([a-zA-Z0-9\-._])(\[[A-Za-z])/g, '$1 $2') 80 | // Add spaces between '[/x]' and texts. 81 | ans = ans.replace(/(\[\/[^\]]+?\])([a-zA-Z0-9\-._])/g, '$1 $2') 82 | 83 | return ans 84 | } 85 | 86 | export async function getZendeskDate(url: string) { 87 | const req = new Promise((rs, rj) => { 88 | GM.xmlHttpRequest({ 89 | method: 'GET', 90 | url: 91 | '/api/v2/help_center/en-us/articles/' + 92 | url.match(/\/articles\/(\d+)/)![1], 93 | fetch: true, 94 | nocache: true, 95 | timeout: 7_000, 96 | onload: (r) => { 97 | try { 98 | rs(r.responseText) 99 | } catch (e) { 100 | rj(e) 101 | } 102 | }, 103 | onabort: () => rj(new Error('Aborted')), 104 | onerror: (e) => rj(e), 105 | ontimeout: () => rj(new Error('Time out')), 106 | }) 107 | }) 108 | let res: Date 109 | await req.then((value) => { 110 | const rsp = JSON.parse(value as string) 111 | res = new Date(rsp.article.created_at) 112 | }) 113 | 114 | const year = res.getFullYear() 115 | const month = res.getMonth() + 1 116 | const day = res.getDate() 117 | return { year, month, day } 118 | } 119 | -------------------------------------------------------------------------------- /packages/userscript/src/types.ts: -------------------------------------------------------------------------------- 1 | export interface Context { 2 | author?: string 3 | bugs?: ResolvedBugs 4 | bugsTranslators?: Translator 5 | translatorColor?: ColorMap 6 | disablePunctuationConverter?: boolean 7 | multiLineCode?: boolean 8 | inList?: boolean 9 | date: DateConstructor | null 10 | title: string 11 | translator: string 12 | url: string 13 | } 14 | 15 | export interface ResolvedBugs { 16 | [id: string]: string 17 | } 18 | 19 | export interface Translator { 20 | [id: string]: string 21 | } 22 | 23 | export interface ColorMap { 24 | [id: string]: string 25 | } 26 | 27 | export type TranslationMappings = [RegExp, string][] 28 | -------------------------------------------------------------------------------- /packages/userscript/src/utils/articleTemplate.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Just a simple file to get the header and footer of an article. 3 | * This needs continuous updates according to https://www.mcbbs.net/thread-1253320-1-1.html#pid23311399. 4 | */ 5 | 6 | import { spxxVersion } from './consts' 7 | 8 | export function getHeader(articleType: string, type: VersionType) { 9 | if (articleType.toLowerCase() !== 'news') { 10 | return '' 11 | } 12 | switch (type) { 13 | case VersionType.Snapshot: 14 | return `> 📅 **每周快照**是 Minecraft Java 版的测试机制,用于新特性的展示和反馈收集。 15 | > 💀 快照有可能导致存档损坏,因此请注意备份,不要直接在你的主存档游玩快照。 16 | > 📒 转载本帖时须要注明原作者以及本帖地址。本帖来自[红石中继站](https://forum.mczwlt.net/category/6) 。 17 | > 📋 部分新特性译名仅供参考,不代表最终结果。 18 | 19 | ---` 20 | case VersionType.PreRelease: 21 | return `> 📅 **预发布版**是 Minecraft Java 版的测试机制,如果该版本作为正式版发布,那么预发布版的游戏文件将与启动器推送的正式版完全相同。 22 | > 🤔 然而,预发布版主要用于服主和 Mod 制作者的预先体验,如果发现重大漏洞,该预发布版会被新的预发布版代替。因此建议普通玩家持观望态度。 23 | > 📒 转载本帖时须要注明原作者以及本帖地址。本帖来自[红石中继站](https://forum.mczwlt.net/category/6) 。 24 | > 📋 部分新特性译名仅供参考,不代表最终结果。 25 | 26 | ---` 27 | case VersionType.ReleaseCandidate: 28 | return `> 📅 **候选版**是 Minecraft Java 版正式版的候选版本,如果发现重大漏洞,该候选版会被新的候选版代替。如果一切正常,该版本将会作为正式版发布。 29 | > 🤗 候选版已可供普通玩家进行抢鲜体验,但仍需当心可能存在的漏洞。 30 | > 📒 转载本帖时须要注明原作者以及本帖地址。本帖来自[红石中继站](https://forum.mczwlt.net/category/6) 。 31 | > 📋 部分新特性译名仅供参考,不代表最终结果。 32 | 33 | ---` 34 | case VersionType.Release: 35 | return `> 📅 **Minecraft Java 版**是指运行在 Windows、macOS 与 Linux 平台上,使用 Java 语言开发的 Minecraft 版本。 36 | > 😎 **正式版**包含所有特性且安全稳定,所有玩家都可以尽情畅享。 37 | > 📒 转载本帖时须要注明原作者以及本帖地址。本帖来自[红石中继站](https://forum.mczwlt.net/category/6) 。 38 | 39 | ---` 40 | 41 | case VersionType.BedrockRelease: 42 | return `> 📅 **Minecraft 基岩版**是指运行在移动平台(Android、iOS)、Windows 10/11、主机(Xbox One、Switch、PlayStation 4)上,使用「基岩引擎」(C++语言)开发的 Minecraft 版本。 43 | > 😎 **正式版**包含所有特性且安全稳定,所有玩家都可以尽情畅享。 44 | > 📒 转载本帖时须要注明原作者以及本帖地址。本帖来自[红石中继站](https://forum.mczwlt.net/category/6) 。 45 | 46 | ---` 47 | 48 | case VersionType.BedrockBeta: 49 | return `> 📅 **测试版**是 Minecraft 基岩版的测试机制,主要用于下一个正式版的特性预览。 50 | > 💀 测试版有可能导致存档损坏,因此请注意备份,不要直接在你的主存档游玩测试版。 51 | > 📒 转载本帖时须要注明原作者以及本帖地址。本帖来自[红石中继站](https://forum.mczwlt.net/category/6) 。 52 | > 📋 部分新特性译名仅供参考,不代表最终结果。 53 | 54 | ---` 55 | 56 | case VersionType.Normal: 57 | default: 58 | return `> 📒 转载本帖时须要注明原作者以及本帖地址。本帖来自[红石中继站](https://forum.mczwlt.net/category/6) 。 59 | 60 | ---` 61 | } 62 | } 63 | 64 | export function getFooter( 65 | articleType: string, 66 | type: VersionType, 67 | serverJar = '自行替换' 68 | ) { 69 | const time = new Date() // why javacript why 70 | 71 | function padTime(time: number) { 72 | return time.toString().padStart(2, '0') 73 | } 74 | 75 | function toHoursAndMinutes(totalMinutes: number) { 76 | const m = Math.abs(totalMinutes) 77 | const minutes = m % 60 78 | const hours = Math.floor(m / 60) 79 | 80 | return `${totalMinutes < 0 ? '+' : '-'}${padTime(hours)}${padTime(minutes)}` 81 | } 82 | 83 | const poweredBy = `=== Powered by SPXX ${spxxVersion} with love === 84 | === Converted at ${time.getFullYear()}-${ 85 | padTime(time.getMonth() + 1) // why +1 javascript 86 | }-${padTime(time.getDate())} ${padTime(time.getHours())}:${padTime( 87 | time.getMinutes() 88 | )} ${toHoursAndMinutes(time.getTimezoneOffset())} ===` 89 | 90 | if (articleType.toLowerCase() !== 'news') { 91 | return `\n${poweredBy}` 92 | } 93 | 94 | switch (type) { 95 | case VersionType.Snapshot: 96 | return ` 97 | 98 | --- 99 | 100 | >🔗 实用链接: 101 | > 1. [官方服务端 jar 下载](${serverJar}) 102 | > 2. [正版启动器下载地址](https://www.minecraft.net/zh-hans/download/) 103 | > 3. [漏洞报告站点(英文)](https://bugs.mojang.com/browse/MC) 104 | > 4. [官方反馈网站(英文)](https://feedback.minecraft.net/hc/en-us) 105 | 106 | --- 107 | 108 | >🎮 如何游玩快照? 109 | > * 对于正版用户:请打开官方启动器,在「配置」选项卡中启用「快照」,选择「最新快照」即可。 110 | > * 对于非正版用户:请先寻找适合自己的启动器。目前绝大多数主流启动器都带有下载功能。如仍有疑惑请到[原版问答](https://forum.mczwlt.net/category/14/)板块提问。 111 | 112 | --- 113 | 114 | > 📰 想了解更多 Minecraft 新闻资讯?>>>[幻翼块讯](https://forum.mczwlt.net/category/6) 115 | 116 | ${poweredBy}` 117 | 118 | case VersionType.PreRelease: 119 | return ` 120 | 121 | --- 122 | 123 | >🔗 实用链接: 124 | > 1. [官方服务端 jar 下载](${serverJar}) 125 | > 2. [正版启动器下载地址](https://www.minecraft.net/zh-hans/download/) 126 | > 3. [漏洞报告站点(英文)](https://bugs.mojang.com/browse/MC) 127 | > 4. [官方反馈网站(英文)](https://feedback.minecraft.net/hc/en-us) 128 | 129 | --- 130 | 131 | >🎮 如何游玩预发布版? 132 | > * 对于正版用户:请打开官方启动器,在「配置」选项卡中启用「快照」,选择「最新快照」即可。 133 | > * 对于非正版用户:请先寻找适合自己的启动器。目前绝大多数主流启动器都带有下载功能。如仍有疑惑请到[原版问答](https://forum.mczwlt.net/category/14/)板块提问。 134 | 135 | --- 136 | 137 | > 📰 想了解更多 Minecraft 新闻资讯?>>>[幻翼块讯](https://forum.mczwlt.net/category/6) 138 | 139 | ${poweredBy}` 140 | 141 | case VersionType.ReleaseCandidate: 142 | return ` 143 | 144 | --- 145 | 146 | >🔗 实用链接: 147 | > 1. [官方服务端 jar 下载](${serverJar}) 148 | > 2. [正版启动器下载地址](https://www.minecraft.net/zh-hans/download/) 149 | > 3. [漏洞报告站点(英文)](https://bugs.mojang.com/browse/MC) 150 | > 4. [官方反馈网站(英文)](https://feedback.minecraft.net/hc/en-us) 151 | 152 | --- 153 | 154 | >🎮 如何游玩候选版本? 155 | > * 对于正版用户:请打开官方启动器,在「配置」选项卡中启用「快照」,选择「最新快照」即可。 156 | > * 对于非正版用户:请先寻找适合自己的启动器。目前绝大多数主流启动器都带有下载功能。如仍有疑惑请到[原版问答](https://forum.mczwlt.net/category/14/)板块提问。 157 | 158 | --- 159 | 160 | > 📰 想了解更多 Minecraft 新闻资讯?>>>[幻翼块讯](https://forum.mczwlt.net/category/6) 161 | 162 | ${poweredBy}` 163 | 164 | case VersionType.Release: 165 | return ` 166 | 167 | --- 168 | 169 | >🔗 实用链接: 170 | > 1. [官方服务端 jar 下载](${serverJar}) 171 | > 2. [正版启动器下载地址](https://www.minecraft.net/zh-hans/download/) 172 | > 3. [漏洞报告站点(英文)](https://bugs.mojang.com/browse/MC) 173 | > 4. [官方反馈网站(英文)](https://feedback.minecraft.net/hc/en-us) 174 | 175 | --- 176 | 177 | >🎮 如何游玩正式版? 178 | > * 对于正版用户:请打开官方启动器,选择「最新版本」即可。 179 | > * 对于非正版用户:请先寻找适合自己的启动器。目前绝大多数主流启动器都带有下载功能。如仍有疑惑请到[原版问答](https://forum.mczwlt.net/category/14/)板块提问。 180 | 181 | --- 182 | 183 | > 📰 想了解更多 Minecraft 新闻资讯?>>>[幻翼块讯](https://forum.mczwlt.net/category/6) 184 | 185 | ${poweredBy}` 186 | 187 | case VersionType.BedrockRelease: 188 | return ` 189 | 190 | --- 191 | 192 | >🔗 实用链接: 193 | > 1. [漏洞报告站点(英文)](https://bugs.mojang.com/browse/MCPE) 194 | > 2. [官方反馈网站(英文)](https://feedback.minecraft.net/hc/en-us) 195 | 196 | --- 197 | 198 | >🎮 如何游玩正式版? 199 | > * 请访问[官方游戏获取地址](https://www.minecraft.net/zh-hans/get-minecraft),根据您所使用的平台获取游戏。 200 | 201 | --- 202 | 203 | > 📰 想了解更多 Minecraft 新闻资讯?>>>[幻翼块讯](https://forum.mczwlt.net/category/6) 204 | 205 | ${poweredBy}` 206 | 207 | case VersionType.BedrockBeta: 208 | return ` 209 | 210 | --- 211 | 212 | >🔗 实用链接: 213 | > 1. [漏洞报告站点(英文)](https://bugs.mojang.com/browse/MCPE) 214 | > 2. [官方反馈网站(英文)](https://feedback.minecraft.net/hc/en-us) 215 | 216 | --- 217 | 218 | >🎮 如何游玩测试版/预览版? 219 | > * 请访问[官方游戏获取地址](https://www.minecraft.net/zh-hans/get-minecraft),根据您所使用的平台获取游戏。 220 | > * 基岩测试版/预览版仅限于 Windows 10/11、Android、iOS、Xbox One 平台。请根据[官方指引](https://www.mcbbs.net/thread-1299939-1-1.html)启用/关闭测试版/预览版。 221 | > * 在新建/编辑地图时,请滑动到「实验性游戏内容(Experiments)」,选取你想体验的实验性内容。 222 | 223 | --- 224 | 225 | > 📰 想了解更多 Minecraft 新闻资讯?>>>[幻翼块讯](https://forum.mczwlt.net/category/6) 226 | 227 | ${poweredBy}` 228 | 229 | case VersionType.Normal: 230 | default: 231 | return ` 232 | 233 | --- 234 | 235 | > 📒 **转载本帖时须注明原作者以及本帖地址。** 236 | > 📰 想了解更多 Minecraft 新闻资讯?>>>[幻翼块讯](https://forum.mczwlt.net/category/6) 237 | 238 | ${poweredBy}` 239 | } 240 | } 241 | 242 | export const enum VersionType { 243 | Snapshot, 244 | PreRelease, 245 | ReleaseCandidate, 246 | Release, 247 | Normal, 248 | BedrockBeta, 249 | BedrockRelease, 250 | } 251 | -------------------------------------------------------------------------------- /packages/userscript/src/utils/autoTranslation.ts: -------------------------------------------------------------------------------- 1 | import { Context, TranslationMappings } from '../types' 2 | 3 | const translators = { 4 | headings: (input: string, ctx: Context): string => { 5 | return translator(input, ctx, [ 6 | // Minecraft.net titles 7 | [/Block of the Week: /gi, '本周方块:'], 8 | [/Taking Inventory: /gi, '背包盘点:'], 9 | [/Around the Block: /gi, '群系漫游:'], 10 | [/A Minecraft Java Snapshot/gi, 'Minecraft Java版 快照'], 11 | [/A Minecraft Java Pre-Release/gi, 'Minecraft Java版 预发布版'], 12 | [/A Minecraft Java Release Candidate/gi, 'Minecraft Java版 候选版本'], 13 | // Bedrock Edition titles 14 | [ 15 | /Minecraft Beta (?:-|——) (.*?) \((.*?)\)/gi, 16 | 'Minecraft 基岩版 Beta $1($2)', 17 | ], 18 | [ 19 | /Minecraft Beta & Preview - (.*?)/g, 20 | 'Minecraft 基岩版 Beta & Preview $1', 21 | ], 22 | [/Minecraft (?:-|——) (.*?) \(Bedrock\)/gi, 'Minecraft 基岩版 $1'], 23 | [ 24 | /Minecraft (?:-|——) (.*?) \((.*?) Only\)/gi, 25 | 'Minecraft 基岩版 $1(仅$2)', 26 | ], 27 | [/Minecraft (?:-|——) (.*?) \((.*?)\)/gi, 'Minecraft 基岩版 $1(仅$2)'], 28 | 29 | // BE subheadings 30 | [/Marketplace/gi, '市场'], 31 | [/Data-Driven/gi, '数据驱动'], 32 | [/Graphical/gi, '图像'], 33 | [/Player/gi, '玩家'], 34 | [/Experimental Features/gi, '实验性特性'], 35 | [/Mobs/gi, '生物'], 36 | [/Features and Bug Fixes/gi, '特性和漏洞修复'], 37 | [/Stability and Performance/gi, '稳定性和性能'], 38 | [/Accessibility/gi, '辅助功能'], 39 | [/Gameplay/gi, '玩法'], 40 | [/Items/gi, '物品'], 41 | [/Blocks/gi, '方块'], 42 | [/User Interface/gi, '用户界面'], 43 | [/Commands/gi, '命令'], 44 | [/Known Issues/gi, '已知问题'], 45 | [/Technical Updates/gi, '技术性更新'], 46 | [/Character Creator/gi, '角色创建器'], 47 | [/Text Components/gi, '文本组件'], 48 | [/Components/gi, '组件'], 49 | [/General/gi, '通用'], 50 | [/Technical Experimental/gi, '实验性技术性更新'], 51 | [/Gametest Framework/gi, 'Gametest 框架'], 52 | [/Gametest Framework (experimental)/gi, 'Gametest 框架(实验性)'], 53 | // JE subheadings 54 | [/Minecraft Snapshot /gi, 'Minecraft 快照 '], 55 | [/ Pre-Release /gi, '-pre'], 56 | [/ Release Candidate /gi, '-rc'], 57 | [/Release Candidate/gi, '候选版本'], 58 | [/New Features in ([^\r\n]+)/gi, '$1 的新增特性'], 59 | [/Technical changes in ([^\r\n]+)/gi, '$1 的技术性修改'], 60 | [/Changes in ([^\r\n]+)/gi, '$1 的修改内容'], 61 | [/Fixed bugs in ([^\r\n]+)/gi, '$1 修复的漏洞'], 62 | ]) 63 | }, 64 | imgCredits: (input: string, ctx: Context) => { 65 | return translator(input, ctx, [ 66 | // Creative Commons image credits 67 | [/Image credit:/gi, '图片来源:'], 68 | [/CC BY-NC-ND/gi, '知识共享 署名-非商业性使用-禁止演绎'], 69 | [/CC BY-NC-SA/gi, '知识共享 署名-非商业性使用-相同方式共享'], 70 | [/CC BY-NC/gi, '知识共享 署名-非商业性使用'], 71 | [/CC BY-ND/gi, '知识共享 署名-禁止演绎'], 72 | [/CC BY-SA/gi, '知识共享 署名-相同方式共享'], 73 | [/CC BY/gi, '知识共享 署名'], 74 | [/Public Domain/gi, '公有领域'], 75 | ]) 76 | }, 77 | punctuation: (input: string, ctx: Context) => { 78 | return translator( 79 | input, 80 | ctx, 81 | [ 82 | [/\[i\]/gi, '[font=楷体]'], 83 | [/\[\/i\]/g, '[/font]'], 84 | ...(ctx.disablePunctuationConverter 85 | ? [] 86 | : ([ 87 | [/,( |$)/g, ','], 88 | [/!( |$)/g, '!'], 89 | [/\.\.\.( |$)/g, '…'], 90 | [/\.( |$)/g, '。'], 91 | [/\?( |$)/g, '?'], 92 | [/( |^)-( |$)/g, ' —— '], 93 | ] as [RegExp, string][])), 94 | ], 95 | (input: string) => { 96 | return quoteTreatment(input, [['“', '”', /"/]]) 97 | } 98 | ) 99 | }, 100 | } 101 | 102 | export default function translate( 103 | input: string, 104 | ctx: Context, 105 | type: (keyof typeof translators)[] | keyof typeof translators 106 | ): string { 107 | if (typeof type === 'string') { 108 | type = [type] 109 | } 110 | for (const t of type) { 111 | input = translators[t](input, ctx) 112 | } 113 | return input 114 | } 115 | 116 | function quoteTreatment( 117 | input: string, 118 | quoteArrays: [string, string, RegExp][] 119 | ) { 120 | for (const quoteArray of quoteArrays) { 121 | const split = input.split(quoteArray[2]) 122 | input = '' 123 | for (let i = 0; i < split.length - 1; i++) { 124 | const element = split[i] 125 | input += element + quoteArray[i % 2] 126 | } 127 | input += split[split.length - 1] 128 | } 129 | return input 130 | } 131 | 132 | function translator( 133 | input: string, 134 | ctx: Context, 135 | mappings: TranslationMappings, 136 | treatment: (input: string, ctx: Context) => string = (input) => input 137 | ): string { 138 | // REPLACE!!!!1 139 | for (const mapping of mappings) { 140 | input = input.replace(mapping[0], mapping[1]) 141 | } 142 | treatment(input, ctx) 143 | 144 | return input 145 | } 146 | -------------------------------------------------------------------------------- /packages/userscript/src/utils/consts.ts: -------------------------------------------------------------------------------- 1 | import * as packageJson from '../../package.json' 2 | 3 | export const spxxVersion = packageJson.version 4 | -------------------------------------------------------------------------------- /packages/userscript/src/utils/converter.ts: -------------------------------------------------------------------------------- 1 | import config from '../config' 2 | import { Context, ResolvedBugs } from '../types' 3 | import translate from './autoTranslation' 4 | import { spxxVersion } from './consts' 5 | 6 | export const converters = { 7 | /** 8 | * Converts a ChildNode to a Markdown string according to the type of the node. 9 | */ 10 | convert: async (node: ChildNode, ctx: Context): Promise => { 11 | if ((node as HTMLElement).classList?.contains('spxx-userscript-ignored')) { 12 | return '' 13 | } 14 | // Listing all possible elements in the document 15 | switch (node.nodeName) { 16 | case 'A': 17 | return converters.a(node as HTMLAnchorElement, ctx) 18 | case 'B': 19 | case 'STRONG': 20 | return converters.strong(node as HTMLElement, ctx) 21 | case 'BLOCKQUOTE': 22 | return converters.blockquote(node as HTMLQuoteElement, ctx) 23 | case 'BR': 24 | return converters.br() 25 | case 'CITE': 26 | return converters.cite(node as HTMLElement, ctx) 27 | case 'CODE': 28 | return converters.code(node as HTMLElement, ctx) 29 | case 'DIV': 30 | case 'SECTION': 31 | return converters.div(node as HTMLDivElement, ctx) 32 | case 'DD': 33 | return converters.dd(node as HTMLElement, ctx) 34 | case 'DL': 35 | return converters.dl(node as HTMLElement, ctx) 36 | case 'DT': 37 | return converters.dt() 38 | case 'EM': 39 | return converters.em(node as HTMLElement, ctx) 40 | case 'H1': 41 | return converters.h1(node as HTMLElement, ctx) 42 | case 'H2': 43 | return converters.h2(node as HTMLElement, ctx) 44 | case 'H3': 45 | return converters.h3(node as HTMLElement, ctx) 46 | case 'H4': 47 | return converters.h4(node as HTMLElement, ctx) 48 | case 'I': 49 | return converters.i(node as HTMLElement, ctx) 50 | case 'IMG': 51 | return converters.img(node as HTMLImageElement) 52 | case 'LI': 53 | return converters.li(node as HTMLElement, ctx) 54 | case 'OL': 55 | return converters.ol(node as HTMLElement, ctx) 56 | case 'P': 57 | return converters.p(node as HTMLElement, ctx) 58 | case 'PICTURE': // TODO: If picture contains important img in the future. Then just attain the last element in the element. 59 | return converters.picture(node as HTMLElement, ctx) 60 | case 'PRE': 61 | return converters.pre(node as HTMLElement, ctx) 62 | case 'SPAN': 63 | return converters.span(node as HTMLElement, ctx) 64 | case 'TABLE': 65 | return converters.table(node as HTMLElement, ctx) 66 | case 'TBODY': 67 | return converters.tbody(node as HTMLElement, ctx) 68 | case 'TH': 69 | case 'TD': 70 | return converters.td(node as HTMLElement, ctx) 71 | case 'TR': 72 | return converters.tr(node as HTMLElement, ctx) 73 | case 'UL': 74 | return converters.ul(node as HTMLElement, ctx) 75 | case '#text': 76 | if (node) { 77 | if (ctx.multiLineCode) { 78 | return node.textContent ? node.textContent : '' 79 | } else 80 | return ((node as Text).textContent as string) 81 | .replace(/[\n\r\t]+/g, '') 82 | .replace(/\s{2,}/g, '') 83 | } else { 84 | return '' 85 | } 86 | case 'BUTTON': 87 | case 'H5': 88 | case 'NAV': 89 | case 'svg': 90 | case 'SCRIPT': 91 | if (node) { 92 | return node.textContent ? node.textContent : '' 93 | } else { 94 | return '' 95 | } 96 | default: 97 | console.warn(`Unknown type: '${node.nodeName}'.`) 98 | if (node) { 99 | return node.textContent ? node.textContent : '' 100 | } else { 101 | return '' 102 | } 103 | } 104 | }, 105 | /** 106 | * Convert child nodes of an HTMLElement to a Markdown string. 107 | */ 108 | recursive: async (ele: HTMLElement, ctx: Context) => { 109 | let ans = '' 110 | 111 | if (!ele) { 112 | return ans 113 | } 114 | 115 | for (const child of Array.from(ele.childNodes)) { 116 | ans += await converters.convert(child, ctx) 117 | } 118 | 119 | return ans 120 | }, 121 | a: async (anchor: HTMLAnchorElement, ctx: Context) => { 122 | const url = resolveUrl(anchor.href) 123 | let ans: string 124 | if (url) { 125 | ans = `[${await converters.recursive(anchor, ctx)}](${url})` 126 | } else { 127 | ans = await converters.recursive(anchor, ctx) 128 | } 129 | 130 | return ans 131 | }, 132 | blockquote: async (ele: HTMLQuoteElement, ctx: Context) => { 133 | const prefix = '> ' 134 | const suffix = '\n' 135 | const ans = `${prefix}${await converters.recursive(ele, ctx)}${suffix}` 136 | 137 | return ans 138 | }, 139 | br: async () => { 140 | const ans = '\n' 141 | 142 | return ans 143 | }, 144 | cite: async (ele: HTMLElement, ctx: Context) => { 145 | const prefix = '—— ' 146 | const suffix = '' 147 | 148 | const ans = `${prefix}${await converters.recursive(ele, ctx)}${suffix}` 149 | 150 | return ans 151 | }, 152 | code: async (ele: HTMLElement, ctx: Context) => { 153 | const prefix = ctx.multiLineCode ? '```\n' : '`' 154 | const suffix = ctx.multiLineCode ? '```' : '`' 155 | 156 | const ans = `${prefix}${await converters.recursive(ele, { 157 | ...ctx, 158 | disablePunctuationConverter: true, 159 | })}${suffix}` 160 | 161 | return ans 162 | }, 163 | div: async (ele: HTMLDivElement, ctx: Context) => { 164 | let ans = await converters.recursive(ele, ctx) 165 | 166 | if (ele.classList.contains('text-center')) { 167 | // no way to center text 168 | // ans = `[/indent][/indent][align=center]${ans}[/align][indent][indent]\n` 169 | } else if (ele.classList.contains('article-image-carousel')) { 170 | // TODO: Image carousel. 171 | /* 172 | *
.article-image-carousel 173 | *
.slick-list 174 | *
.slick-track 175 | * *
.slick-slide [.slick-cloned] 176 | *
177 | *
.slick-slide-carousel 178 | * .article-image-carousel__image 179 | *
.article-image-carousel__caption 180 | */ 181 | const prefix = `[/indent][/indent][album]\n` 182 | const suffix = `\n[/album][indent][indent]\n` 183 | const slides: [string, string][] = [] 184 | const findSlides = async ( 185 | ele: HTMLDivElement | HTMLImageElement 186 | ): Promise => { 187 | if (ele.classList.contains('slick-cloned')) { 188 | return 189 | } 190 | if ( 191 | ele.nodeName === 'IMG' && 192 | ele.classList.contains('article-image-carousel__image') 193 | ) { 194 | slides.push([resolveUrl((ele as HTMLImageElement).src), ' ']) 195 | } else if ( 196 | ele.nodeName === 'DIV' && 197 | ele.classList.contains('article-image-carousel__caption') 198 | ) { 199 | if (slides.length > 0) { 200 | slides[slides.length - 1][1] = `[b]${await converters.recursive( 201 | ele, 202 | ctx 203 | )}[/b]` 204 | } 205 | } else { 206 | for (const child of Array.from(ele.childNodes)) { 207 | if (child.nodeName === 'DIV' || child.nodeName === 'IMG') { 208 | await findSlides(child as HTMLDivElement | HTMLImageElement) 209 | } 210 | } 211 | } 212 | } 213 | await findSlides(ele) 214 | if (shouldUseAlbum(slides)) { 215 | ans = `${prefix}${slides 216 | .map(([url, caption]) => `[aimg=${url}]${caption}[/aimg]`) 217 | .join('\n')}${suffix}` 218 | } else if (slides.length > 0) { 219 | ans = `[/indent][/indent][align=center]${slides 220 | .map(([url, caption]) => `[img]${url}[/img]\n${caption}`) 221 | .join('\n')}[/align][indent][indent]\n` 222 | } else { 223 | ans = '' 224 | } 225 | } else if (ele.classList.contains('video')) { 226 | // TODO: Video. 227 | ans = 228 | '\nhttps://www.bilibili.com/video/BV1GJ411x7h7【请替换此处视频链接的BV号】\n' 229 | } else if ( 230 | ele.classList.contains('quote') || 231 | ele.classList.contains('attributed-quote') 232 | ) { 233 | ans = `\n> ${ans}\n` 234 | } else if (ele.classList.contains('MC_articleHeroA_category')) { 235 | ans = `\n###### ${ans}\n` 236 | } else if ( 237 | ele.classList.contains('MC_imageGridA') || 238 | ele.classList.contains('MC_socialShareA') 239 | ) { 240 | // End of the content. 241 | ans = '' 242 | } else if (ele.classList.contains('MC_articleHeroA_attribution')) { 243 | // No need to convert 244 | ans = '' 245 | } else if (ele.classList.contains('modal')) { 246 | // Unknown useless content 247 | ans = '' 248 | } 249 | // else if (ele.classList.contains('end-with-block')) { 250 | // ans = ans.trimRight() + '[img=16,16]https://ooo.0o0.ooo/2017/01/30/588f60bbaaf78.png[/img]' 251 | // } 252 | 253 | return ans 254 | }, 255 | dt: async () => { 256 | // const ans = `${converters.rescure(ele)}:` 257 | 258 | // return ans 259 | return '' 260 | }, 261 | dl: async (ele: HTMLElement, ctx: Context) => { 262 | // The final
after converted will contains an footer comma ',' 263 | // So I don't add any comma before '译者'. 264 | const ans = `\n\n${await converters.recursive( 265 | ele, 266 | ctx 267 | )}\n【本文排版借助了:SPXX v${spxxVersion}】\n\n` 268 | return ans 269 | }, 270 | dd: async (ele: HTMLElement, ctx: Context) => { 271 | let ans = '' 272 | 273 | if (ele.classList.contains('pubDate')) { 274 | // Published: 275 | // `pubDate` is like '2019-03-08T10:00:00.876+0000'. 276 | const date = ele.attributes.getNamedItem('data-value') 277 | if (date) { 278 | ans = `**【${ctx.translator} 译自[url=${ 279 | ctx.url 280 | }][color=#388d40][u]官网 ${date.value.slice( 281 | 0, 282 | 4 283 | )} 年 ${date.value.slice(5, 7)} 月 ${date.value.slice( 284 | 8, 285 | 10 286 | )} 日发布的 ${ctx.title}[/u][/color][/url];原作者 ${ctx.author}】**` 287 | } else { 288 | ans = `[b]【${ctx.translator} 译自[url=${ctx.url}][color=#388d40][u]官网 哪 年 哪 月 哪 日发布的 ${ctx.title}[/u][/color][/url]】[/b]` 289 | } 290 | } else { 291 | // Written by: 292 | ctx.author = await converters.recursive(ele, ctx) 293 | } 294 | 295 | return ans 296 | }, 297 | em: async (ele: HTMLElement, ctx: Context) => { 298 | const ans = `_${await converters.recursive(ele, ctx)}_` 299 | 300 | return ans 301 | }, 302 | h1: async (ele: HTMLElement, ctx: Context) => { 303 | const prefix = '# ' 304 | const suffix = '' 305 | const rawInner = await converters.recursive(ele, ctx) 306 | const inner = rawInner.toUpperCase() 307 | const ans = `${prefix}${translate(`${inner}`, ctx, [ 308 | 'headings', 309 | 'punctuation', 310 | ]).replace(/[\n\r]+/g, ' ')}${suffix}\n\n` 311 | 312 | return ans 313 | }, 314 | h2: async (ele: HTMLElement, ctx: Context) => { 315 | if (isBlocklisted(ele.textContent!)) return '' 316 | 317 | const prefix = '## ' 318 | const suffix = '' 319 | const rawInner = await converters.recursive(ele, ctx) 320 | const inner = rawInner.toUpperCase() 321 | const ans = `${prefix}${translate(`${inner}`, ctx, [ 322 | 'headings', 323 | 'punctuation', 324 | ]).replace(/[\n\r]+/g, ' ')}${suffix}\n\n` 325 | 326 | return ans 327 | }, 328 | h3: async (ele: HTMLElement, ctx: Context) => { 329 | const prefix = '### ' 330 | const suffix = '' 331 | const inner = await converters.recursive(ele, ctx) 332 | const ans = `${prefix}${translate(`${inner}`, ctx, [ 333 | 'headings', 334 | 'punctuation', 335 | ]).replace(/[\n\r]+/g, ' ')}${suffix}\n\n` 336 | 337 | return ans 338 | }, 339 | h4: async (ele: HTMLElement, ctx: Context) => { 340 | const prefix = '#### ' 341 | const suffix = '' 342 | const inner = await converters.recursive(ele, ctx) 343 | const ans = `${prefix}${translate(`${inner}`, ctx, [ 344 | 'headings', 345 | 'punctuation', 346 | ]).replace(/[\n\r]+/g, ' ')}${suffix}\n\n` 347 | 348 | return ans 349 | }, 350 | i: async (ele: HTMLElement, ctx: Context) => { 351 | const ans = `_${await converters.recursive(ele, ctx)}_` 352 | 353 | return ans 354 | }, 355 | img: async (img: HTMLImageElement) => { 356 | if (img.alt === 'Author image') { 357 | return '' 358 | } 359 | 360 | // let w: number | undefined 361 | // let h: number | undefined 362 | 363 | // if (img.classList.contains('attributed-quote__image')) { 364 | // // for in-quote avatar image 365 | // h = 92 366 | // w = 53 367 | // } else if (img.classList.contains('mr-3')) { 368 | // // for attributor avatar image 369 | // h = 121 370 | // w = 82 371 | // } 372 | 373 | const prefix = `![${img.alt}]` 374 | const imgUrl = resolveUrl(img.src) 375 | if (imgUrl === '') return '' // in case of empty image 376 | 377 | const ans = `\n\n${prefix}(${imgUrl})\n` 378 | 379 | return ans 380 | }, 381 | li: async (ele: HTMLElement, ctx: Context) => { 382 | let ans: string 383 | 384 | let depth = 0 385 | let parent = ele.parentElement 386 | while (parent) { 387 | if (parent.nodeName === 'UL' || parent.nodeName === 'OL') { 388 | depth++ 389 | } 390 | parent = parent.parentElement 391 | } 392 | 393 | let suffix = '\n' 394 | if (ele.querySelector('ul')) { 395 | suffix = '' 396 | } 397 | 398 | if (isBlocklisted(ele.textContent!)) { 399 | return '' 400 | } else { 401 | const inner = await converters.recursive(ele, { ...ctx, inList: true }) 402 | ans = `${' '.repeat(depth - 1)}${ 403 | ele.parentElement!.nodeName === 'UL' 404 | ? '- ' 405 | : `${Array.from(ele.parentElement!.children).indexOf(ele) + 1}. ` 406 | }${translateBugs(inner, ctx)}${suffix}` 407 | } 408 | 409 | return ans 410 | }, 411 | ol: async (ele: HTMLElement, ctx: Context) => { 412 | let prefix = '' 413 | if (ele.parentElement?.nodeName === 'LI') { 414 | prefix = '\n' 415 | } 416 | const ans = `${prefix}${await converters.recursive(ele, ctx)}\n` 417 | 418 | return ans 419 | }, 420 | p: async (ele: HTMLElement, ctx: Context) => { 421 | const inner = await converters.recursive(ele, ctx) 422 | 423 | let ans: string 424 | 425 | if (ele.classList.contains('MC_articleHeroA_header_subheadline')) { 426 | ans = `### ${translate(inner, ctx, 'headings')}\n\n` 427 | } else if ( 428 | ele.querySelector('strong') !== null && 429 | ele.querySelector('strong')!.textContent === 'Posted:' 430 | ) { 431 | return '' 432 | } else if (isBlocklisted(ele.textContent!)) { 433 | return '' 434 | } else if (ele.innerHTML === ' ') { 435 | return '\n' 436 | } else if ( 437 | /\s{0,}/.test(ele.textContent!) && 438 | ele.querySelectorAll('img').length === 1 439 | ) { 440 | return inner 441 | } else { 442 | if (ctx.inList) { 443 | ans = inner 444 | } else { 445 | ans = `${translate(inner, ctx, ['punctuation', 'imgCredits'])}\n\n` 446 | } 447 | } 448 | 449 | return ans 450 | }, 451 | picture: async (ele: HTMLElement, ctx: Context) => { 452 | const ans = await converters.recursive(ele, ctx) 453 | return ans 454 | }, 455 | pre: async (ele: HTMLElement, ctx: Context) => { 456 | const ans = await converters.recursive(ele, { ...ctx, multiLineCode: true }) 457 | return ans 458 | }, 459 | span: async (ele: HTMLElement, ctx: Context) => { 460 | const ans = await converters.recursive(ele, ctx) 461 | 462 | if (ele.classList.contains('bedrock-server') || ele.classList.contains('MC_Effect_TextHighlightA')) { 463 | // Inline code. 464 | const prefix = '`' 465 | const suffix = '`' 466 | return `${prefix}${await converters.recursive(ele, { 467 | ...ctx, 468 | disablePunctuationConverter: true, 469 | })}${suffix}` 470 | } else if (ele.classList.contains('strikethrough')) { 471 | // Strikethrough text. 472 | const prefix = '~~' 473 | const suffix = '~~' 474 | return `${prefix}${ans}${suffix}` 475 | } else if ( 476 | ele.childElementCount === 1 && 477 | ele.firstElementChild!.nodeName === 'IMG' 478 | ) { 479 | // Image. 480 | const img = ele.firstElementChild! as HTMLImageElement 481 | return await converters.img(img) 482 | } 483 | 484 | return ans 485 | }, 486 | strong: async (ele: HTMLElement, ctx: Context) => { 487 | const ans = `**${await converters.recursive(ele, ctx)}**` 488 | 489 | return ans 490 | }, 491 | table: async (ele: HTMLElement, ctx: Context) => { 492 | const ans = `\n[table]\n${await converters.recursive(ele, ctx)}[/table]\n` 493 | 494 | return ans 495 | }, 496 | tbody: async (ele: HTMLElement, ctx: Context) => { 497 | const ans = await converters.recursive(ele, ctx) 498 | 499 | return ans 500 | }, 501 | td: async (ele: HTMLElement, ctx: Context) => { 502 | const ans = `[td]${await converters.recursive(ele, ctx)}[/td]` 503 | 504 | return ans 505 | }, 506 | tr: async (ele: HTMLElement, ctx: Context) => { 507 | const ans = `[tr]${await converters.recursive(ele, ctx)}[/tr]\n` 508 | 509 | return ans 510 | }, 511 | ul: async (ele: HTMLElement, ctx: Context) => { 512 | let prefix = '' 513 | let suffix = '\n' 514 | 515 | if (ele.parentElement?.nodeName === 'LI') { 516 | prefix = '\n' 517 | suffix = '' 518 | } 519 | const ans = `${prefix}${await converters.recursive(ele, ctx)}${suffix}` 520 | 521 | return ans 522 | }, 523 | } 524 | 525 | /** 526 | * Resolve relative URLs. 527 | */ 528 | export function resolveUrl(url: string) { 529 | if (url[0] === '/') { 530 | return `https://${location.host}${url}` 531 | } else { 532 | return url 533 | } 534 | } 535 | 536 | /** 537 | * Get bugs from BugCenter. 538 | */ 539 | export async function getBugs(): Promise { 540 | return new Promise((rs, rj) => { 541 | GM.xmlHttpRequest({ 542 | method: 'GET', 543 | url: config.bugCenter.translation, 544 | fetch: true, 545 | nocache: true, 546 | timeout: 7_000, 547 | onload: (r) => { 548 | try { 549 | rs(JSON.parse(r.responseText)) 550 | } catch (e) { 551 | rj(e) 552 | } 553 | }, 554 | onabort: () => rj(new Error('Aborted')), 555 | onerror: (e) => rj(e), 556 | ontimeout: () => rj(new Error('Time out')), 557 | }) 558 | }) 559 | } 560 | 561 | export async function getBugsTranslators(): Promise { 562 | return new Promise((rs, rj) => { 563 | GM.xmlHttpRequest({ 564 | method: 'GET', 565 | url: config.bugCenter.translator, 566 | fetch: true, 567 | nocache: true, 568 | timeout: 7_000, 569 | onload: (r) => { 570 | try { 571 | rs(JSON.parse(r.responseText)) 572 | } catch (e) { 573 | rj(e) 574 | } 575 | }, 576 | onabort: () => rj(new Error('Aborted')), 577 | onerror: (e) => rj(e), 578 | ontimeout: () => rj(new Error('Time out')), 579 | }) 580 | }) 581 | } 582 | 583 | export async function getTranslatorColor(): Promise { 584 | return new Promise((rs, rj) => { 585 | GM.xmlHttpRequest({ 586 | method: 'GET', 587 | url: config.bugCenter.color, 588 | fetch: true, 589 | nocache: true, 590 | timeout: 7_000, 591 | onload: (r) => { 592 | try { 593 | rs(JSON.parse(r.responseText)) 594 | } catch (e) { 595 | rj(e) 596 | } 597 | }, 598 | onabort: () => rj(new Error('Aborted')), 599 | onerror: (e) => rj(e), 600 | ontimeout: () => rj(new Error('Time out')), 601 | }) 602 | }) 603 | } 604 | 605 | /** 606 | * Replace untranslated bugs. 607 | */ 608 | function translateBugs(str: string, ctx: Context): string { 609 | if ( 610 | str.startsWith('[url=https://bugs.mojang.com/browse/MC-') && 611 | ctx.bugs != null // nullish 612 | ) { 613 | const id = str.slice(36, str.indexOf(']')) 614 | const data = ctx.bugs[id] 615 | 616 | if (data) { 617 | let bugColor = '#388d40' 618 | if (ctx.bugsTranslators[id]) { 619 | const bugTranslator = ctx.bugsTranslators[id] 620 | if (ctx.translatorColor[bugTranslator]) { 621 | bugColor = ctx.translatorColor[bugTranslator] 622 | } 623 | } 624 | 625 | return `[${id}](https://bugs.mojang.com/browse/${id}) - ${data}` 626 | } else { 627 | return str 628 | } 629 | } else { 630 | return str 631 | } 632 | } 633 | 634 | /** 635 | * Determine if we should use album, depending on image count. 636 | */ 637 | function shouldUseAlbum(slides: [string, string][]) { 638 | const enableAlbum = true 639 | return enableAlbum 640 | ? slides.length > 1 && slides.every(([_, caption]) => caption === ' ') // do not use album if there is any caption 641 | : false 642 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 643 | // slides.every(([_, caption]) => caption === ' ') 644 | } 645 | 646 | function isBlocklisted(text: string): boolean { 647 | const blocklist: string[] = [ 648 | 'Information on the Minecraft Preview and Beta:', 649 | 'While the version numbers between Preview and Beta are different, there is no difference in game content', 650 | 'These work-in-progress versions can be unstable and may not be representative of final version quality', 651 | 'Minecraft Preview is available on Xbox, Windows 10/11, and iOS devices. More information can be found at aka.ms/PreviewFAQ', 652 | 'The beta is available on Android (Google Play). To join or leave the beta, see aka.ms/JoinMCBeta for detailed instructions', 653 | ] 654 | return blocklist 655 | .map((i) => { 656 | return i.replace(/\p{General_Category=Space_Separator}*/u, '') 657 | }) 658 | .some((block) => 659 | text 660 | .trim() 661 | .trim() 662 | .replace(/\p{General_Category=Space_Separator}*/u, '') 663 | .includes(block) 664 | ) 665 | } 666 | -------------------------------------------------------------------------------- /packages/userscript/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowSyntheticDefaultImports": true, 4 | "forceConsistentCasingInFileNames": true, 5 | "downlevelIteration": true, 6 | "outDir": ".", 7 | "rootDir": ".", 8 | "resolveJsonModule": true 9 | }, 10 | "include": ["package.json", "rollup.config.ts"], 11 | "extends": "../../tsconfig.base.json" 12 | } 13 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - 'packages/*' 3 | -------------------------------------------------------------------------------- /tsconfig.base.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowSyntheticDefaultImports": true, 4 | "esModuleInterop": true, 5 | "module": "esnext", 6 | "target": "esnext", 7 | "outDir": "./out", 8 | "rootDir": "./src", 9 | "lib": ["esnext", "DOM", "DOM.Iterable"], 10 | "sourceMap": true, 11 | "strict": true, 12 | "incremental": true, 13 | "moduleResolution": "bundler" 14 | }, 15 | "include": ["src"], 16 | "exclude": ["node_modules", "src/test", ".rollup.cache"] 17 | } 18 | -------------------------------------------------------------------------------- /tsconfig.eslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "types": ["@types/node"], 4 | "noEmit": true, 5 | "allowJs": true 6 | }, 7 | "extends": "./tsconfig.base.json", 8 | "include": ["tests/**/*.ts", "tools/**/*.ts", "eslint.config.js"] 9 | } 10 | --------------------------------------------------------------------------------