├── .data └── .keep ├── .devcontainer ├── Dockerfile └── devcontainer.json ├── .editorconfig ├── .eslintrc.js ├── .github └── workflows │ ├── codeql-analysis.yml │ └── node.js.yml ├── .gitignore ├── LICENSE.md ├── README.md ├── codecept.conf.js ├── components ├── About.js ├── AdminFeedbacks.js ├── AdminPage.js ├── App.css ├── App.js ├── ColorText.js ├── CookieConsent.js ├── ErrorPage.js ├── Footer.js ├── GithubCorner.js ├── HeaderTips.js ├── ItemDescription.js ├── Logo.js ├── Main.js ├── MainSearchForm.js ├── NewFeedback.js ├── QuickResults.js ├── Recommends.js ├── ResultSection.js ├── Ribbon.js ├── SearchForm.js ├── Section.js ├── StatLikesResults.js ├── Support.js ├── SwapButton.js ├── TagFeedbacks.js ├── TagItem.js ├── TagItems.js ├── Tags.js ├── Tips.js └── TwoWeeksCounterBars.js ├── crawl.js ├── docs └── images │ ├── app_main_screen.png │ ├── faces.png │ └── logo.png ├── ecosystem.config.js ├── favicon.ico ├── genHtmlTagNames.js ├── jsconfig.json ├── lighthouserc.js ├── makeIndex.js ├── nodemon.json ├── package-lock.json ├── package.json ├── scripts ├── download.js ├── fake.js ├── fixLikesUserData.js ├── glitch.js ├── htmlcheck.js └── invite.js ├── server.js ├── speccheck.js ├── specfix.js ├── steps.d.ts ├── testing ├── datatables │ ├── index.js │ ├── tables │ │ ├── decoration.js │ │ ├── history.js │ │ └── likes.js │ └── types.js ├── helpers │ ├── dbHelper.js │ └── envHelper.js ├── output │ └── .keep ├── pages │ ├── Common.js │ ├── Detail.js │ └── Main.js ├── plugins │ └── dbPlugin.js ├── reports │ └── .keep ├── screenshots │ ├── base │ │ ├── b_s_yes_history_single_row.png │ │ ├── img_a_doubt_single_history_row.png │ │ ├── p_ul_no_history_single_row.png │ │ ├── result_doubt.png │ │ ├── result_no.png │ │ └── result_yes.png │ └── diff │ │ └── .keep ├── scripts │ └── testsBootstrap.js ├── steps │ └── steps_file.js └── tests │ ├── bugSpecParseEmTagContentSkipped_test.js │ ├── canincludeTags_test.js │ ├── cookiesConsent_test.js │ ├── forms_test.js │ ├── historyTable_test.js │ ├── mainPageDecoration_test.js │ ├── mainPage_test.js │ └── recommendations_test.js └── utils.js /.data/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CyberLight/caninclude/cc9188c06aef32a7c3abaf123957096d22d09390/.data/.keep -------------------------------------------------------------------------------- /.devcontainer/Dockerfile: -------------------------------------------------------------------------------- 1 | # See here for image contents: https://github.com/microsoft/vscode-dev-containers/tree/v0.194.3/containers/debian/.devcontainer/base.Dockerfile 2 | 3 | # [Choice] Debian version: bullseye, buster, stretch 4 | ARG VARIANT="buster" 5 | FROM mcr.microsoft.com/vscode/devcontainers/base:0-${VARIANT} 6 | 7 | # ** [Optional] Uncomment this section to install additional packages. ** 8 | # RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ 9 | # && apt-get -y install --no-install-recommends 10 | 11 | RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ 12 | && curl -sL https://deb.nodesource.com/setup_14.x | sudo bash - \ 13 | && apt-get update && apt-get install nodejs 14 | 15 | RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ 16 | && apt-get install -y locales \ 17 | && sed -i -e 's/# en_US.UTF-8 UTF-8/en_US.UTF-8 UTF-8/' /etc/locale.gen \ 18 | && dpkg-reconfigure locales \ 19 | && update-locale LANG=en_US.UTF-8 20 | 21 | RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ 22 | && apt-get install -y chromium 23 | 24 | RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ 25 | && apt-get install -y pkg-config build-essential libpixman-1-dev libcairo2-dev libpango1.0-dev libjpeg62-turbo-dev libgif-dev python 26 | 27 | RUN apt-get autoremove -y \ 28 | && apt-get clean -y 29 | 30 | ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD true 31 | ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | // For format details, see https://aka.ms/devcontainer.json. For config options, see the README at: 2 | // https://github.com/microsoft/vscode-dev-containers/tree/v0.194.3/containers/debian 3 | { 4 | "name": "Debian", 5 | "build": { 6 | "dockerfile": "Dockerfile", 7 | // Update 'VARIANT' to pick an Debian version: bullseye, buster, stretch 8 | "args": { "VARIANT": "bullseye" } 9 | }, 10 | 11 | // Set *default* container specific settings.json values on container create. 12 | "settings": {}, 13 | 14 | // Add the IDs of extensions you want installed when the container is created. 15 | "extensions": [ 16 | "dbaeumer.vscode-eslint", 17 | "ms-vscode.atom-keybindings", 18 | "editorconfig.editorconfig", 19 | "ms-azuretools.vscode-docker" 20 | ], 21 | 22 | // Use 'forwardPorts' to make a list of ports inside the container available locally. 23 | "forwardPorts": [3000], 24 | 25 | // Uncomment to use the Docker CLI from inside the container. See https://aka.ms/vscode-remote/samples/docker-from-docker. 26 | // "mounts": [ "source=/var/run/docker.sock,target=/var/run/docker.sock,type=bind" ], 27 | 28 | // Uncomment when using a ptrace-based debugger like C++, Go, and Rust 29 | // "runArgs": [ "--cap-add=SYS_PTRACE", "--security-opt", "seccomp=unconfined" ], 30 | 31 | // A command string or list of command arguments to run inside the container after it is created 32 | "postCreateCommand": "npm i", 33 | 34 | // Comment out connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root. 35 | "remoteUser": "vscode" 36 | } 37 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*.js] 4 | indent_style = space 5 | indent_size = 2 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | end_of_line = lf 10 | # editorconfig-tools is unable to ignore longs strings or urls 11 | max_line_length = off 12 | 13 | [CHANGELOG.md] 14 | indent_size = false -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | browser: true, 4 | node: true, 5 | es2021: true, 6 | }, 7 | extends: [ 8 | 'airbnb-base', 9 | 'plugin:codeceptjs/recommended', 10 | ], 11 | parserOptions: { 12 | ecmaVersion: 12, 13 | sourceType: 'module', 14 | }, 15 | rules: { 16 | }, 17 | }; 18 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ master ] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [ master ] 20 | schedule: 21 | - cron: '17 6 * * 4' 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ubuntu-latest 27 | permissions: 28 | actions: read 29 | contents: read 30 | security-events: write 31 | 32 | strategy: 33 | fail-fast: false 34 | matrix: 35 | language: [ 'javascript' ] 36 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ] 37 | # Learn more: 38 | # https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed 39 | 40 | steps: 41 | - name: Checkout repository 42 | uses: actions/checkout@v2 43 | 44 | # Initializes the CodeQL tools for scanning. 45 | - name: Initialize CodeQL 46 | uses: github/codeql-action/init@v1 47 | with: 48 | languages: ${{ matrix.language }} 49 | # If you wish to specify custom queries, you can do so here or in a config file. 50 | # By default, queries listed here will override any specified in a config file. 51 | # Prefix the list here with "+" to use these queries and those in the config file. 52 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 53 | 54 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 55 | # If this step fails, then you should remove it and run the build manually (see below) 56 | - name: Autobuild 57 | uses: github/codeql-action/autobuild@v1 58 | 59 | # ℹ️ Command-line programs to run using the OS shell. 60 | # 📚 https://git.io/JvXDl 61 | 62 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 63 | # and modify them (or add more) to build your code if your project 64 | # uses a compiled language 65 | 66 | #- run: | 67 | # make bootstrap 68 | # make release 69 | 70 | - name: Perform CodeQL Analysis 71 | uses: github/codeql-action/analyze@v1 72 | -------------------------------------------------------------------------------- /.github/workflows/node.js.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: Node.js CI 5 | 6 | on: 7 | push: 8 | branches: [ master ] 9 | pull_request: 10 | branches: [ master ] 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: ubuntu-latest 16 | 17 | strategy: 18 | matrix: 19 | node-version: [14.x] 20 | 21 | steps: 22 | - uses: actions/checkout@v2 23 | - name: Use Node.js ${{ matrix.node-version }} 24 | uses: actions/setup-node@v1 25 | with: 26 | node-version: ${{ matrix.node-version }} 27 | - name: Install npm packages 28 | run: | 29 | npm install 30 | npm run build --if-present 31 | env: 32 | PUPPETEER_SKIP_CHROMIUM_DOWNLOAD: true 33 | - name: Run e2e tests 34 | run: npm run crawl && npm run test:ci 35 | - name: Publish e2e tests report in xunit/junit xml format 36 | uses: EnricoMi/publish-unit-test-result-action@v1.6 37 | if: always() 38 | with: 39 | check_name: e2e tests results 40 | comment_title: e2e tests statistics 41 | github_token: ${{ secrets.GITHUB_TOKEN }} 42 | files: testing/reports/**/*.xml 43 | - name: "Archive tests artifacts" 44 | uses: actions/upload-artifact@v2 45 | if: always() 46 | with: 47 | name: e2e-artifacts 48 | path: | 49 | testing/output 50 | testing/reports/**/*.xml 51 | testing/screenshots 52 | - name: "Lighthouse CI step" 53 | run: | 54 | echo "{ \"serverBaseUrl\": \"$LHI_URL\", \"token\": \"$LHI_BUILD_KEY\" }" > lhci_settings.json 55 | npm install -g @lhci/cli@0.4.x 56 | npm run crawl 57 | lhci autorun || true 58 | env: 59 | LHI_URL: ${{ secrets.LHI_URL }} 60 | LHI_BUILD_KEY: ${{ secrets.LHI_BUILD_TOKEN }} 61 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | *.lcov 24 | 25 | # nyc test coverage 26 | .nyc_output 27 | 28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 29 | .grunt 30 | 31 | # Bower dependency directory (https://bower.io/) 32 | bower_components 33 | 34 | # node-waf configuration 35 | .lock-wscript 36 | 37 | # Compiled binary addons (https://nodejs.org/api/addons.html) 38 | build/Release 39 | 40 | # Dependency directories 41 | node_modules/ 42 | jspm_packages/ 43 | 44 | # Snowpack dependency directory (https://snowpack.dev/) 45 | web_modules/ 46 | 47 | # TypeScript cache 48 | *.tsbuildinfo 49 | 50 | # Optional npm cache directory 51 | .npm 52 | 53 | # Optional eslint cache 54 | .eslintcache 55 | 56 | # Microbundle cache 57 | .rpt2_cache/ 58 | .rts2_cache_cjs/ 59 | .rts2_cache_es/ 60 | .rts2_cache_umd/ 61 | 62 | # Optional REPL history 63 | .node_repl_history 64 | 65 | # Output of 'npm pack' 66 | *.tgz 67 | 68 | # Yarn Integrity file 69 | .yarn-integrity 70 | 71 | # dotenv environment variables file 72 | .env 73 | .env.test 74 | 75 | # parcel-bundler cache (https://parceljs.org/) 76 | .cache 77 | .parcel-cache 78 | 79 | # Next.js build output 80 | .next 81 | 82 | # Nuxt.js build / generate output 83 | .nuxt 84 | dist 85 | 86 | # Gatsby files 87 | .cache/ 88 | # Comment in the public line in if your project uses Gatsby and not Next.js 89 | # https://nextjs.org/blog/next-9-1#public-directory-support 90 | # public 91 | 92 | # vuepress build output 93 | .vuepress/dist 94 | 95 | # Serverless directories 96 | .serverless/ 97 | 98 | # FuseBox cache 99 | .fusebox/ 100 | 101 | # DynamoDB Local files 102 | .dynamodb/ 103 | 104 | # TernJS port file 105 | .tern-port 106 | 107 | # Stores VSCode versions used for testing VSCode extensions 108 | .vscode-test 109 | 110 | # yarn v2 111 | 112 | .yarn/cache 113 | .yarn/unplugged 114 | .yarn/build-state.yml 115 | .pnp.* 116 | 117 | spec.json 118 | searchstat.json 119 | 120 | .data/* 121 | !.data/.keep 122 | 123 | .DS_Store 124 | 125 | fakes/ 126 | 127 | lhci_settings.json 128 | .lighthouseci 129 | 130 | specindex.json 131 | spec_fixed.json 132 | 133 | testing/output/* 134 | !testing/output/.keep 135 | 136 | testing/screenshots/diff/* 137 | !testing/screenshots/diff/.keep 138 | 139 | testing/reports/* 140 | !testing/reports/.keep 141 | 142 | public/ -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright (c) 2011-2020 CyberLight. 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # CAN I INCLUDE 2 | 3 |
4 | 5 |
6 | 7 | This project provides functionality to test whether one tag can be included in another. Based on information from [HTML Spec WHATWG](https://html.spec.whatwg.org/) 8 | 9 |
10 | 11 |
12 | 13 | Please, see **Demo** [here](https://caninclude.glitch.me) 14 | 15 |
16 | 17 |
18 | 19 | ## Development environment 20 | * [VSCode](https://code.visualstudio.com/) 21 | * [VSCode Remote Containers](https://code.visualstudio.com/docs/remote/containers#_installation) 22 | * Clone `git clone https://github.com/CyberLight/caninclude` 23 | * Go to cloned project repo folder `cd caninclude` 24 | * Open in VSCode `code .` 25 | * In popup menu click by `Reopen in Container` 26 | * Whew! 27 | 28 | ## Setup spec.json for app 29 | * `npm run crawl` - this command crawl html spec page and make json data for app 30 | 31 | ## How to run app in development 32 | * `npm run dev` - this open app using **nodemon** 33 | * Go to url `http://localhost:3000` in your browser 34 | * That's all! 35 | 36 | ### How to run app in production 37 | * Need to set env variables from [Environment variables](#environment-variables) section 38 | * `npm start` - this command launch an app using **pm2** using **ecosystem.config.js** 39 | 40 | ## Environment variables 41 | * `COOKIE_KEY` - a key for sign cookies `type: String` 42 | * `FEEDBACK_DAILY_LIMIT` - a limit count of feedbacks daily `type: Integer` 43 | * `RECOMMEND_CLEAR_CACHE_CRON_TIME` - string in [cron time format](https://github.com/kelektiv/node-cron#cron-ranges). Default value: `0 */30 * * * *` (every 30 minutes) 44 | * `MAIN_PAGE_DECORATION_TYPE` - main page decoration mode or type `type: String`, possible values: 45 | * **NY_LIGHT_RIBBON** 46 | * `LOGO_URL` - URL of the logo file to be included in the src attribute of the img element 47 | * `LOGO_ALT` - text for alt attribute of img element 48 | 49 | ## Project structure 50 | * **.data** - a folder for sqlite database 51 | * **.devcontainer** - a folder with VSCode Remote Containers configuration 52 | * **components** - a folder with server components that was written in **Preact**. 53 | * **scripts** - a folder with helper scripts for a project 54 | * **crawl.js** - a script for crawling html spec page 55 | * **ecosystem.config.js** - a configuration file for **pm2** 56 | * **nodemon.json** - a configuration file for **nodemon** 57 | * **server.js** - main and huge file which contains all routes and logic for an app 58 | * **specfix.js** - a script for making some additional json data transformations 59 | * **utils.js** - file with helper data managers and helper functions 60 | 61 | ## Project routes that are important 62 | * `/` - Main page with some counters by usage of app and results for each tags 63 | * `/can/include?parent=&child=` - renders page with full information and result of ability to include one tag into another 64 | * `/admin/feedbacks` - ui for manage and view feedbacks 65 | * For ability to access to this route, you need: 66 | * `npm run gen:invite role=admin` 67 | * This script will output url like this: `/invites/81493f1f9a306f64417b91960f6ded3b/apply` 68 | * Go to your browser and concatenate `http://localhost:3000` and `/invites/81493f1f9a306f64417b91960f6ded3b/apply` and press `Enter` 69 | * After that you can access to `/admin/feedbacks` 70 | 71 | ## License 72 | See the [LICENSE](LICENSE.md) file for license rights and limitations (MIT). -------------------------------------------------------------------------------- /codecept.conf.js: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line import/no-extraneous-dependencies 2 | const { setHeadlessWhen } = require('@codeceptjs/configure'); 3 | const bootstrap = require('./testing/scripts/testsBootstrap'); 4 | // turn on headless mode when running with HEADLESS=true environment variable 5 | // export HEADLESS=true && npx codeceptjs run 6 | setHeadlessWhen(process.env.HEADLESS); 7 | 8 | exports.config = { 9 | tests: './testing/tests/*_test.js', 10 | output: './testing/output', 11 | helpers: { 12 | Puppeteer: { 13 | url: 'http://localhost:3000', 14 | show: false, 15 | windowSize: '1200x900', 16 | waitForNavigation: 'networkidle0', 17 | chrome: { 18 | executablePath: '/usr/bin/chromium', 19 | args: [ 20 | '--no-sandbox', 21 | '--disable-setuid-sandbox', 22 | '--disable-dev-shm-usage', 23 | '--no-first-run', 24 | '--disable-gpu', 25 | '--disable-component-update', 26 | '--disable-extensions', 27 | '--remote-debugging-address=0.0.0.0', 28 | '--remote-debugging-port=9222', 29 | '--font-render-hinting=none' 30 | ], 31 | defaultViewport: { 32 | width: 1200, 33 | height: 900, 34 | deviceScaleFactor: 1, 35 | }, 36 | }, 37 | waitForTimeout: 1000, 38 | }, 39 | DbHelper: { 40 | require: './testing/helpers/dbHelper.js', 41 | }, 42 | EnvHelper: { 43 | require: './testing/helpers/envHelper.js', 44 | }, 45 | ResembleHelper: { 46 | require: 'codeceptjs-resemblehelper', 47 | screenshotFolder: './testing/output/', 48 | baseFolder: './testing/screenshots/base/', 49 | diffFolder: './testing/screenshots/diff/', 50 | }, 51 | }, 52 | include: { 53 | I: './testing/steps/steps_file.js', 54 | MainPage: './testing/pages/Main.js', 55 | CommonPage: './testing/pages/Common.js', 56 | DetailPage: './testing/pages/Detail.js', 57 | DataTables: './testing/datatables/index.js', 58 | }, 59 | // eslint-disable-next-line global-require 60 | bootstrap: bootstrap.bootstrap, 61 | teardown: bootstrap.teardown, 62 | mocha: { 63 | bail: true, 64 | reporterOptions: { 65 | mochaFile: 'testing/reports/result.xml', 66 | }, 67 | }, 68 | name: 'caninclude', 69 | plugins: { 70 | pauseOnFail: {}, 71 | retryFailedStep: { 72 | enabled: true, 73 | }, 74 | tryTo: { 75 | enabled: true, 76 | }, 77 | screenshotOnFail: { 78 | enabled: true, 79 | uniqueScreenshotNames: true, 80 | fullPageScreenshots: true, 81 | }, 82 | dbPlugin: { 83 | enabled: true, 84 | require: './testing/plugins/dbPlugin.js', 85 | }, 86 | }, 87 | }; 88 | -------------------------------------------------------------------------------- /components/About.js: -------------------------------------------------------------------------------- 1 | const { html, Component } = require('htm/preact'); 2 | 3 | class About extends Component { 4 | // eslint-disable-next-line class-methods-use-this 5 | render({ children, show }) { 6 | return show && html` 7 |
8 | ${children} 9 |
`; 10 | } 11 | } 12 | 13 | module.exports = About; 14 | -------------------------------------------------------------------------------- /components/AdminFeedbacks.js: -------------------------------------------------------------------------------- 1 | const { html, Component } = require('htm/preact'); 2 | 3 | class AdminFeedbacks extends Component { 4 | render({ feedbacks = [], currentPage, totalPages, show }) { 5 | return show && html` 6 |
7 |

Feedbacks

8 | 47 |
48 | ${new Array(totalPages).fill(0).map((_, index) => { 49 | const pageNumber = index + 1; 50 | return pageNumber === currentPage ? html`${pageNumber} ` : html`${pageNumber} ` 51 | })} 52 |
53 |
` || null; 54 | } 55 | } 56 | 57 | module.exports = AdminFeedbacks; -------------------------------------------------------------------------------- /components/AdminPage.js: -------------------------------------------------------------------------------- 1 | const { html, Component } = require('htm/preact'); 2 | const Main = require('./Main'); 3 | const AdminFeedbacks = require('./AdminFeedbacks'); 4 | 5 | class AdminPage extends Component { 6 | render({ request, ...other }) { 7 | return html` 8 | <${Main} request="${request}"> 9 |
10 | <${AdminFeedbacks} ...${other} show /> 11 |
12 | `; 13 | } 14 | } 15 | 16 | module.exports = AdminPage; -------------------------------------------------------------------------------- /components/App.js: -------------------------------------------------------------------------------- 1 | const { html, Component } = require('htm/preact'); 2 | const MainSearchForm = require('./MainSearchForm'); 3 | const Tags = require('./Tags'); 4 | const Section = require('./Section'); 5 | const ResultSection = require('./ResultSection'); 6 | const Tips = require('./Tips'); 7 | const About = require('./About'); 8 | const QuickResults = require('./QuickResults'); 9 | const CookieConsent = require('./CookieConsent'); 10 | const NewFeedback = require('./NewFeedback'); 11 | const TagFeedbacks = require('./TagFeedbacks'); 12 | const Ribbon = require('./Ribbon'); 13 | const Main = require('./Main'); 14 | const Recommends = require('./Recommends'); 15 | 16 | class App extends Component { 17 | // eslint-disable-next-line class-methods-use-this 18 | render({ 19 | form, 20 | tags, 21 | tips = [], 22 | tagStats, 23 | request, 24 | specVersion, 25 | votes, 26 | userAcceptCookie, 27 | showFeedback, 28 | showFeedbacks, 29 | feedback, 30 | feedbacks, 31 | canAddFeedback, 32 | recommendResult, 33 | decorationType, 34 | logoUrl, 35 | logoAlt, 36 | headerTipHtmlContent, 37 | }) { 38 | const hasTags = tags.length > 0; 39 | const showNYRibbon = decorationType === 'NY_LIGHT_RIBBON'; 40 | const showNYChina = decorationType === 'NY_CHINA'; 41 | 42 | return html` 43 | <${Main} form="${form}" tags="${tags}" request="${request}" headerTipHtmlContent="${headerTipHtmlContent}"> 44 | ${!userAcceptCookie && html`<${CookieConsent}/>`} 45 | ${!hasTags && showNYRibbon && html`<${Ribbon} />`} 46 | ${!hasTags && showNYChina && html`
`} 47 |
48 | <${Recommends} recommendation="${recommendResult}" /> 49 | <${Tips} tips="${tips}"/> 50 | <${NewFeedback} request="${request}" form="${form}" show="${showFeedback}"/> 51 | <${TagFeedbacks} request="${request}" feedbacks="${feedbacks}" show="${showFeedbacks}" /> 52 | <${Tags} show="${hasTags}"> 53 | 54 | 55 | 56 | 57 | <${Section} tag="${tags[0]}" accent="first"/> 58 | <${ResultSection} ..."${tags[1]}" request="${request}" votes="${votes}" feedback="${feedback}" userAcceptCookie="${userAcceptCookie}" canAddFeedback="${canAddFeedback}"/> 59 | <${Section} tag="${tags[2]}" accent="last"/> 60 | 61 | <${About} show="${!hasTags}"> 62 | <${MainSearchForm} ...${form} show="${!hasTags}" specVersion="${specVersion}" logoUrl="${logoUrl}" logoAlt="${logoAlt}"/> 63 |
64 | <${QuickResults} tagStats="${tagStats}"/> 65 |
66 |
This site helps you understand which tag you can include in another using the WHATWG HTML specification
67 |
* This is an alpha version and uses a simple algorithm to test whether one tag can be included in another.
68 | 69 |
70 | `; 71 | } 72 | } 73 | 74 | module.exports = App; 75 | -------------------------------------------------------------------------------- /components/ColorText.js: -------------------------------------------------------------------------------- 1 | const { html, Component } = require('htm/preact'); 2 | 3 | class ColorText extends Component { 4 | render({ children }) { 5 | const colors = ['char-red', 'char-green', 'char-yellow']; 6 | return children.split('').map((ch, index) => html``); 7 | } 8 | } 9 | 10 | module.exports = ColorText; -------------------------------------------------------------------------------- /components/CookieConsent.js: -------------------------------------------------------------------------------- 1 | const { html, Component } = require('htm/preact'); 2 | 3 | class CookieConsent extends Component { 4 | render() { 5 | return html` 6 | `; 14 | } 15 | } 16 | 17 | module.exports = CookieConsent; -------------------------------------------------------------------------------- /components/ErrorPage.js: -------------------------------------------------------------------------------- 1 | const { html, Component } = require('htm/preact'); 2 | const Main = require('./Main'); 3 | const ColorText = require('./ColorText'); 4 | 5 | class ErrorPage extends Component { 6 | render({ request }) { 7 | return html` 8 | <${Main} request="${request}"> 9 |
10 |
11 |
12 |

<${ColorText}>Something went wrong!

13 |

14 | You did something wrong: 15 |

    16 |
  • Entered data in the wrong format
  • 17 |
  • Or is it a mistake in the logic of the web application
  • 18 |
  • Or you are a hacker :)
  • 19 |
  • Or are you attentive QA
  • 20 |
21 |

Please, go back and try again

22 |

23 |
24 |
25 | `; 26 | } 27 | } 28 | 29 | module.exports = ErrorPage; -------------------------------------------------------------------------------- /components/Footer.js: -------------------------------------------------------------------------------- 1 | const { html, Component } = require('htm/preact'); 2 | const { shortenNumber } = require('../utils'); 3 | const TwoWeeksCounterBars = require('./TwoWeeksCounterBars'); 4 | 5 | class Footer extends Component { 6 | // eslint-disable-next-line class-methods-use-this 7 | render({ 8 | count, uniqCount, twoWeeksStat, twoWeeksStatTotalCount, 9 | }) { 10 | const thanksTo = [ 11 | { href: 'https://caniuse.com', text: 'Can I Use' }, 12 | { href: 'https://html.spec.whatwg.org', text: 'HTML Spec WHATWG' }, 13 | { href: 'https://developer.mozilla.org', text: 'MDN' }, 14 | { href: 'https://pepelsbey.net/author/', text: 'Vadim Makeev' }, 15 | { href: 'https://htmlacademy.ru/', text: 'htmlacademy' }, 16 | ]; 17 | 18 | return html` 19 |
20 |
21 |

22 | Special thanks to: ${thanksTo.map((link) => html`${link.text}, `)} 23 |

24 |

25 | Built by @CyberLight a lone developer. 26 |

27 |

Counter: ${shortenNumber(count)} req | ${shortenNumber(uniqCount)} uniq | ${new Date().toJSON().slice(0, 10)}

28 | <${TwoWeeksCounterBars} bars="${twoWeeksStat}" total="${twoWeeksStatTotalCount}"> 29 |
30 |
`; 31 | } 32 | } 33 | 34 | module.exports = Footer; 35 | -------------------------------------------------------------------------------- /components/GithubCorner.js: -------------------------------------------------------------------------------- 1 | const { html, Component } = require('htm/preact'); 2 | 3 | class GithubCorner extends Component { 4 | render(props) { 5 | return html` 6 | 7 | 12 | `; 13 | } 14 | } 15 | 16 | module.exports = GithubCorner; -------------------------------------------------------------------------------- /components/HeaderTips.js: -------------------------------------------------------------------------------- 1 | const { html, Component } = require('htm/preact'); 2 | 3 | class HeaderTips extends Component { 4 | // eslint-disable-next-line class-methods-use-this 5 | render({ content }) { 6 | return (content && html`
`) || null; 7 | } 8 | } 9 | 10 | module.exports = HeaderTips; 11 | -------------------------------------------------------------------------------- /components/ItemDescription.js: -------------------------------------------------------------------------------- 1 | const { html, Component } = require('htm/preact'); 2 | 3 | class ItemDescription extends Component { 4 | render({ content }) { 5 | return html` 6 | 7 | Description: 8 | ${content} 9 | `; 10 | } 11 | } 12 | 13 | module.exports = ItemDescription; -------------------------------------------------------------------------------- /components/Logo.js: -------------------------------------------------------------------------------- 1 | const { html, Component } = require('htm/preact'); 2 | const ColorText = require('./ColorText'); 3 | 4 | class Logo extends Component { 5 | // eslint-disable-next-line class-methods-use-this 6 | render({ logoUrl, logoAlt }) { 7 | return ( 8 | logoUrl && html`` 9 | ) || html`

<${ColorText}>Can I Include*

`; 10 | } 11 | } 12 | 13 | module.exports = Logo; 14 | -------------------------------------------------------------------------------- /components/Main.js: -------------------------------------------------------------------------------- 1 | const { html, Component } = require('htm/preact'); 2 | const SearchForm = require('./SearchForm'); 3 | const Footer = require('./Footer'); 4 | const GithubCorner = require('./GithubCorner'); 5 | const HeaderTips = require('./HeaderTips'); 6 | 7 | class Main extends Component { 8 | // eslint-disable-next-line class-methods-use-this 9 | render({ 10 | children, form, tags, request, headerTipHtmlContent, 11 | }) { 12 | const hasTags = tags && tags.length > 0; 13 | return html` 14 |
15 |
16 | <${HeaderTips} content=${headerTipHtmlContent}/> 17 | <${SearchForm} ...${form} show="${hasTags}"/> 18 | <${GithubCorner}/> 19 |
20 | ${children} 21 | <${Footer} 22 | count="${(request && request.count) || '0'}" 23 | uniqCount="${(request && request.uniqCount) || '0'}" 24 | twoWeeksStat="${(request && request.twoWeeksStat) || []}" 25 | twoWeeksStatTotalCount="${(request && request.twoWeeksStatTotalCount) || 1}" 26 | /> 27 |
`; 28 | } 29 | } 30 | 31 | module.exports = Main; 32 | -------------------------------------------------------------------------------- /components/MainSearchForm.js: -------------------------------------------------------------------------------- 1 | const { html, Component } = require('htm/preact'); 2 | const SwapButton = require('./SwapButton'); 3 | const Logo = require('./Logo'); 4 | 5 | class MainSearchForm extends Component { 6 | // eslint-disable-next-line class-methods-use-this 7 | render({ 8 | parent = '', child = '', show = true, specVersion, logoUrl, logoAlt, 9 | }) { 10 | return show && html` 11 | `; 24 | } 25 | } 26 | 27 | module.exports = MainSearchForm; 28 | -------------------------------------------------------------------------------- /components/NewFeedback.js: -------------------------------------------------------------------------------- 1 | const { html, Component } = require('htm/preact'); 2 | 3 | class NewFeedback extends Component { 4 | render({ request, show, form }) { 5 | return show && html` 6 | 16 | `; 17 | } 18 | } 19 | 20 | module.exports = NewFeedback; -------------------------------------------------------------------------------- /components/QuickResults.js: -------------------------------------------------------------------------------- 1 | const { html, Component } = require('htm/preact'); 2 | 3 | class QuickResults extends Component { 4 | // eslint-disable-next-line class-methods-use-this 5 | mapCanIncludeClass(type) { 6 | return `table__col--${type.toLowerCase()}`; 7 | } 8 | 9 | render({ tagStats = [] }) { 10 | return tagStats && html` 11 |
12 |

Last ${tagStats.length} Quick Results

13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | ${tagStats.map(({ 22 | child, parent, canInclude, count, 23 | }) => html` 24 | 25 | 26 | 27 | 28 | 29 | 30 | `)} 31 |
ChildParentCan Include?CountLink to
${child}${parent}${canInclude}${count}result
32 |
`; 33 | } 34 | } 35 | 36 | module.exports = QuickResults; 37 | -------------------------------------------------------------------------------- /components/Recommends.js: -------------------------------------------------------------------------------- 1 | const { html, Component } = require('htm/preact'); 2 | 3 | class Recommends extends Component { 4 | // eslint-disable-next-line class-methods-use-this 5 | render({ recommendation }) { 6 | return (recommendation && html` 7 |
8 | 9 | Recommends with Attenuation Factor: ${recommendation.attenuation_factor.toFixed(10)} 10 | 11 | 12 | Also looking at this pair of tags ${`<${recommendation.child}/>`} inside ${`<${recommendation.parent}/>`} 16 | 17 |
`) || null; 18 | } 19 | } 20 | 21 | module.exports = Recommends; 22 | -------------------------------------------------------------------------------- /components/ResultSection.js: -------------------------------------------------------------------------------- 1 | const { html, Component } = require('htm/preact'); 2 | 3 | class ResultSection extends Component { 4 | getIconClass(props) { 5 | if (props['success']) { 6 | return 'section-result__icon--success'; 7 | } else if (props['fail']) { 8 | return 'section-result__icon--fail'; 9 | } else if (props['doubt']) { 10 | return 'section-result__icon--doubt'; 11 | } 12 | return 'section-result__icon--unknown'; 13 | } 14 | 15 | getSectionClass(props) { 16 | if (props['success']) { 17 | return 'tag__section-result--success'; 18 | } else if (props['fail']) { 19 | return 'tag__section-result--fail'; 20 | } else if (props['doubt']) { 21 | return 'tag__section-result--doubt'; 22 | } 23 | return 'tag__section-result--unknown'; 24 | } 25 | 26 | render(props) { 27 | const votes = props.votes; 28 | return html` 29 |
30 |

Result of the ability to include a tag in a tag

31 |
32 |
33 |
34 |
${props.text}
35 |
36 |
37 | 38 | ${votes.likes} 39 |
40 |
41 | 42 | ${votes.dislikes} 43 |
44 | ${ props.canAddFeedback && html` 45 |
46 | 47 | new 48 |
` 49 | } 50 |
51 | 52 | list [${props.feedback.count}] 53 |
54 |
55 |
`; 56 | } 57 | } 58 | 59 | module.exports = ResultSection; -------------------------------------------------------------------------------- /components/Ribbon.js: -------------------------------------------------------------------------------- 1 | const { html, Component } = require('htm/preact'); 2 | 3 | class Ribbon extends Component { 4 | // eslint-disable-next-line class-methods-use-this 5 | render() { 6 | return html`
    7 | ${new Array(30).fill(0).map(() => html`
  • `)} 8 |
`; 9 | } 10 | } 11 | 12 | module.exports = Ribbon; 13 | -------------------------------------------------------------------------------- /components/SearchForm.js: -------------------------------------------------------------------------------- 1 | const { html, Component } = require('htm/preact'); 2 | const SwapButton = require('./SwapButton'); 3 | 4 | class SearchForm extends Component { 5 | render({ parent = '', child = '', show = true }) { 6 | return show && html` 7 | `; 21 | } 22 | } 23 | 24 | module.exports = SearchForm; -------------------------------------------------------------------------------- /components/Section.js: -------------------------------------------------------------------------------- 1 | const { html, Component } = require('htm/preact'); 2 | const TagItems = require('./TagItems'); 3 | const Support = require('./Support'); 4 | 5 | class Section extends Component { 6 | render({ tag, accent }) { 7 | return tag && html` 8 |
9 |

${accent} tag section

10 | ${tag.tags.list.map(item => `<${item}/>`).join(', ')} 11 |
12 |

Categories

13 | <${TagItems} items="${tag.props.Categories}"> 14 |
15 |
16 |

Contexts in which this element can be used

17 | <${TagItems} items="${tag.props.ContextsInWhichThisElementCanBeUsed}"> 18 |
19 |
20 |

Content model

21 | <${TagItems} items="${tag.props.ContentModel}"> 22 |
23 | <${Support} data="${tag.support}"/> 24 |
`; 25 | } 26 | } 27 | 28 | module.exports = Section; -------------------------------------------------------------------------------- /components/StatLikesResults.js: -------------------------------------------------------------------------------- 1 | const { html, Component } = require('htm/preact'); 2 | 3 | class StatLikesResults extends Component { 4 | // eslint-disable-next-line class-methods-use-this 5 | render({ 6 | items = [], className = '', title = 'items', statisticsKey = 'last_most_tags', 7 | }) { 8 | return items && html` 9 |
10 |

Last ${items.length} ${title}

11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | ${items.map(({ child, parent, display }) => html` 19 | 20 | 21 | 22 | 23 | 24 | `)} 25 |
ChildParentCountLink to
${child}${parent}${display}result
26 |
`; 27 | } 28 | } 29 | 30 | module.exports = StatLikesResults; 31 | -------------------------------------------------------------------------------- /components/Support.js: -------------------------------------------------------------------------------- 1 | const { html, Component } = require('htm/preact'); 2 | 3 | class Support extends Component { 4 | render({ data }) { 5 | return html` 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | ${Object.entries(data).map(([browser, row]) => 14 | html`${Object.values(row) 15 | .map(cell => html``)}`)} 16 |
BrowserWeb HTMLWeb APICanIUse
${browser}${cell}
17 | `; 18 | } 19 | } 20 | 21 | module.exports = Support; -------------------------------------------------------------------------------- /components/SwapButton.js: -------------------------------------------------------------------------------- 1 | const { html, Component } = require('htm/preact'); 2 | 3 | class SwapButton extends Component { 4 | render(props) { 5 | return html` 6 | `; 11 | } 12 | } 13 | 14 | module.exports = SwapButton; -------------------------------------------------------------------------------- /components/TagFeedbacks.js: -------------------------------------------------------------------------------- 1 | const { html, Component } = require('htm/preact'); 2 | 3 | class TagFeedbacks extends Component { 4 | 5 | getText(feedback) { 6 | if (feedback.approved) { 7 | return feedback.text; 8 | } 9 | return feedback.text.length > 0 ? `${feedback.text.slice(0, 50)}...` : feedback.text; 10 | } 11 | 12 | render({ feedbacks = [], request, show }) { 13 | return show && feedbacks && feedbacks.length && html` 14 | ` || null; 35 | } 36 | } 37 | 38 | module.exports = TagFeedbacks; -------------------------------------------------------------------------------- /components/TagItem.js: -------------------------------------------------------------------------------- 1 | const { html, Component } = require('htm/preact'); 2 | 3 | class TagItem extends Component { 4 | // eslint-disable-next-line class-methods-use-this 5 | renderItem(item) { 6 | const content = item.elements.map((element) => ( 7 | typeof element === 'string' 8 | ? element : `${element.text}`), 11 | []).join(''); 12 | 13 | return html`
  • `; 14 | } 15 | 16 | render({ item }) { 17 | return item && this.renderItem(item); 18 | } 19 | } 20 | 21 | module.exports = TagItem; 22 | -------------------------------------------------------------------------------- /components/TagItems.js: -------------------------------------------------------------------------------- 1 | const { html, Component } = require('htm/preact'); 2 | const TagItem = require('./TagItem'); 3 | 4 | class TagItems extends Component { 5 | // eslint-disable-next-line class-methods-use-this 6 | render({ items }) { 7 | return html` 8 |
      9 | ${items.map((item) => html`<${TagItem} item="${item}">`)} 10 |
    `; 11 | } 12 | } 13 | 14 | module.exports = TagItems; 15 | -------------------------------------------------------------------------------- /components/Tags.js: -------------------------------------------------------------------------------- 1 | const { html, Component } = require('htm/preact'); 2 | 3 | class Tags extends Component { 4 | render({ children, show }) { 5 | return show && html` 6 |
    7 | ${children} 8 |
    `; 9 | } 10 | } 11 | 12 | module.exports = Tags; -------------------------------------------------------------------------------- /components/Tips.js: -------------------------------------------------------------------------------- 1 | const { html, Component } = require('htm/preact'); 2 | 3 | class Tips extends Component { 4 | // eslint-disable-next-line class-methods-use-this 5 | render({ tips = [] }) { 6 | return (tips && tips.length && tips.map((tip) => html` 7 |
    8 |
      9 | ${tip.messages.map((message) => html`
    • `)} 10 |
    11 |
    `)) || null; 12 | } 13 | } 14 | 15 | module.exports = Tips; 16 | -------------------------------------------------------------------------------- /components/TwoWeeksCounterBars.js: -------------------------------------------------------------------------------- 1 | const { html, Component } = require('htm/preact'); 2 | const { getBarCssByValues } = require('../utils'); 3 | 4 | class TwoWeeksCounterBars extends Component { 5 | render({ bars = [], total = 1 }) { 6 | return html` 7 | `; 21 | } 22 | } 23 | 24 | module.exports = TwoWeeksCounterBars; 25 | -------------------------------------------------------------------------------- /crawl.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const util = require('util'); 3 | 4 | const writeFileAsync = util.promisify(fs.writeFile); 5 | 6 | const puppeteer = require('puppeteer'); 7 | 8 | const ViewPort = [1920, 1080]; 9 | const Html5SpecURL = 'https://html.spec.whatwg.org/#the-html-element'; 10 | 11 | const options = { 12 | args: [ 13 | `--window-size=${ViewPort}`, 14 | '--no-sandbox', 15 | '--disable-setuid-sandbox', 16 | '--remote-debugging-address=0.0.0.0', 17 | '--remote-debugging-port=9222', 18 | ], 19 | handleSIGINT: true, 20 | executablePath: '/usr/bin/chromium', 21 | headless: true, 22 | slowMo: 0, 23 | dumpio: false, 24 | }; 25 | 26 | (async function start() { 27 | const browser = await puppeteer.launch(options); 28 | const page = await browser.newPage(); 29 | await page.goto(Html5SpecURL, { waitUntil: 'load', timeout: 0 }); 30 | const version = await page.$eval('#living-standard .pubdate', (el) => el.textContent); 31 | 32 | const result = await page.$$eval('h4[id^="the-"]~.element', (elements) => { 33 | function collectElements(el) { 34 | const foundElements = []; 35 | let parentNode = null; 36 | const IncludeHTMLNodeNames = ['#text', 'EM']; 37 | 38 | const treeWalker = document.createTreeWalker( 39 | el, 40 | // eslint-disable-next-line no-bitwise 41 | NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT, 42 | { 43 | acceptNode(node) { 44 | return (( 45 | node.nodeName === 'A' || (IncludeHTMLNodeNames.includes(node.nodeName) && node.parentNode === parentNode) 46 | ) && NodeFilter.FILTER_ACCEPT) || NodeFilter.FILTER_SKIP; 47 | }, 48 | }, 49 | ); 50 | 51 | parentNode = treeWalker.currentNode; 52 | let currentNode = treeWalker.nextNode(); 53 | while (currentNode) { 54 | foundElements.push(currentNode.nodeName === 'A' ? { text: currentNode.innerText, href: currentNode.href, hashText: new URL(currentNode.href).hash } : currentNode.textContent); 55 | currentNode = treeWalker.nextNode(); 56 | } 57 | 58 | return foundElements; 59 | } 60 | 61 | function nearest(element, selector) { 62 | if (!element) return null; 63 | if (element.matches(selector)) return element; 64 | if (!element.previousElementSibling) return null; 65 | return nearest(element.previousElementSibling, selector); 66 | } 67 | 68 | function parseTagNames(head) { 69 | return { 70 | href: head.querySelector('a').href, 71 | list: Array.from(head.querySelectorAll('code')).map((e) => e.innerText), 72 | }; 73 | } 74 | 75 | function parseSection(section) { 76 | const bySectionName = (el) => ['Categories:', 'Content model:', 'Contexts in which this element can be used:'].includes(el.head); 77 | const normalize = (s) => (s.endsWith(':') ? s.slice(0, -1) : s); 78 | const capitalize = (s) => s.split(' ').map((p) => `${p.charAt(0).toUpperCase()}${p.slice(1)}`).join(''); 79 | const toSectionObject = (o, el) => { 80 | o[capitalize(normalize(el.head))] = el.params; 81 | return o; 82 | }; 83 | 84 | const accumulateSection = (accum, el) => { 85 | if (el.tagName === 'DT') { 86 | return accum.concat({ head: el.innerText, params: [] }); 87 | } 88 | const keywords = Array.from(el.querySelectorAll('a')).map((a) => ({ text: a.innerText, href: a.href, hashText: new URL(a.href).hash })); 89 | // eslint-disable-next-line no-param-reassign 90 | accum[accum.length - 1].params = accum[accum.length - 1].params.concat({ 91 | keywords, elements: collectElements(el), textContent: el.innerText, 92 | }); 93 | return accum; 94 | }; 95 | 96 | return Array.from(section.children) 97 | .reduce(accumulateSection, []) 98 | .filter(bySectionName) 99 | .reduce(toSectionObject, {}); 100 | } 101 | 102 | function parseSupport(el) { 103 | if (!el) return null; 104 | const strongElement = el.querySelector('strong'); 105 | return { 106 | tag: (strongElement && strongElement.nextSibling.textContent.trim()) || null, 107 | browsers: Array.from(el.querySelectorAll('span')) 108 | .map((span) => Array.from(span.querySelectorAll('span')).map((element) => element.innerText)) 109 | .filter((arr) => arr && arr.length), 110 | }; 111 | } 112 | 113 | function reduceSupport(data) { 114 | const sites = ['WebHTMLElement', 'WebAPI', 'caniuse']; 115 | const browsers = sites.reduce((accum, site) => { 116 | if (!data[site]) return accum; 117 | const siteBrowsers = data[site].support.browsers; 118 | // eslint-disable-next-line no-restricted-syntax 119 | for (const [siteBrowser] of siteBrowsers) { 120 | accum.add(siteBrowser); 121 | } 122 | return accum; 123 | }, new Set()); 124 | 125 | const table = {}; 126 | const cache = {}; 127 | // eslint-disable-next-line no-restricted-syntax 128 | for (const site of sites) { 129 | // eslint-disable-next-line no-restricted-syntax 130 | for (const browserName of browsers) { 131 | let supportBrowsers; 132 | if (data[site]) { 133 | supportBrowsers = cache[site] || Object.fromEntries(data[site].support.browsers); 134 | if (!cache[site]) cache[site] = supportBrowsers; 135 | } 136 | table[browserName] = table[browserName] || {}; 137 | table[browserName][site] = table[browserName][site] || {}; 138 | table[browserName][site] = (data[site] && supportBrowsers[browserName]) || '--'; 139 | } 140 | } 141 | return table; 142 | } 143 | 144 | function getStatusesNearH4(h4) { 145 | const statuses = {}; 146 | let currentNode = h4; 147 | 148 | const next = () => { 149 | currentNode = currentNode.nextElementSibling; 150 | }; 151 | 152 | next(); 153 | if (currentNode.matches && currentNode.matches('div.status')) { 154 | statuses.caniuse = { 155 | support: parseSupport(currentNode.querySelector('.support')), 156 | }; 157 | next(); 158 | } 159 | if (currentNode.matches && currentNode.matches('div.mdn-anno')) { 160 | const a = currentNode.querySelector('.feature a'); 161 | statuses.WebHTMLElement = { 162 | link: a.href, 163 | name: a.innerText, 164 | support: parseSupport(currentNode.querySelector('.support')), 165 | }; 166 | next(); 167 | } 168 | if (currentNode.matches && currentNode.matches('div.mdn-anno')) { 169 | const a = currentNode.querySelector('.feature a'); 170 | statuses.WebAPI = { 171 | link: a.href, 172 | name: a.innerText, 173 | support: parseSupport(currentNode.querySelector('.support')), 174 | }; 175 | } 176 | 177 | return reduceSupport(statuses); 178 | } 179 | 180 | return elements.map((el) => { 181 | const h4 = nearest(el, 'h4'); 182 | const statuses = getStatusesNearH4(h4); 183 | return { 184 | tags: parseTagNames(h4), 185 | props: parseSection(el), 186 | support: statuses, 187 | }; 188 | }); 189 | }); 190 | 191 | await writeFileAsync('spec.json', JSON.stringify({ version, result }, ' ', 2)); 192 | await browser.close(); 193 | }()); 194 | -------------------------------------------------------------------------------- /docs/images/app_main_screen.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CyberLight/caninclude/cc9188c06aef32a7c3abaf123957096d22d09390/docs/images/app_main_screen.png -------------------------------------------------------------------------------- /docs/images/faces.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CyberLight/caninclude/cc9188c06aef32a7c3abaf123957096d22d09390/docs/images/faces.png -------------------------------------------------------------------------------- /docs/images/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CyberLight/caninclude/cc9188c06aef32a7c3abaf123957096d22d09390/docs/images/logo.png -------------------------------------------------------------------------------- /ecosystem.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | apps: [{ 3 | name: "webapp", 4 | script: "./server.js", 5 | instances: 3, 6 | exec_mode: "cluster", 7 | max_memory_restart: '300M', 8 | time: true, 9 | env_production: { 10 | "NODE_ENV": "production", 11 | } 12 | }] 13 | }; 14 | -------------------------------------------------------------------------------- /favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CyberLight/caninclude/cc9188c06aef32a7c3abaf123957096d22d09390/favicon.ico -------------------------------------------------------------------------------- /genHtmlTagNames.js: -------------------------------------------------------------------------------- 1 | const util = require('util'); 2 | const fs = require('fs'); 3 | 4 | const readFile = util.promisify(fs.readFile); 5 | // const writeFile = util.promisify(fs.writeFile); 6 | 7 | (async function start() { 8 | const specContent = await readFile('./spec.json'); 9 | const specJson = JSON.parse(specContent); 10 | const tagNames = new Set(specJson.result.reduce( 11 | (tagMap, tag) => tagMap.concat(tag.tags.list), 12 | [], 13 | )); 14 | // eslint-disable-next-line no-console 15 | console.warn(JSON.stringify([...tagNames])); 16 | }()); 17 | -------------------------------------------------------------------------------- /jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowJs": true 4 | } 5 | } -------------------------------------------------------------------------------- /lighthouserc.js: -------------------------------------------------------------------------------- 1 | const settings = require('./lhci_settings.json'); 2 | 3 | module.exports = { 4 | ci: { 5 | collect: { 6 | url: [ 7 | "http://localhost:3000/", 8 | "http://localhost:3000/can/include/?child=h2&parent=button", 9 | ], 10 | settings: { 11 | chromeFlags: "--disable-gpu --no-sandbox --disable-setuid-sandbox" 12 | } 13 | }, 14 | assert: { 15 | preset: "lighthouse:no-pwa", 16 | assertions: { 17 | "dom-size": ["error", { "maxNumericValue": 3000 }], 18 | "offscreen-images": "off", 19 | "color-contrast": "off", 20 | "tap-targets": "off" 21 | }, 22 | }, 23 | upload: { 24 | target: 'lhci', 25 | serverBaseUrl: settings.serverBaseUrl, 26 | token: settings.token 27 | }, 28 | }, 29 | }; -------------------------------------------------------------------------------- /makeIndex.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs').promises; 2 | 3 | function copyObj(o) { 4 | return JSON.parse(JSON.stringify(o)); 5 | } 6 | 7 | (async function start() { 8 | const parsedDb = await fs.readFile('./spec_fixed.json').then((c) => JSON.parse(c)); 9 | const { version } = parsedDb; 10 | const { keywordsMapping } = parsedDb; 11 | const dbIndex = parsedDb.result.reduce((o, el) => { 12 | const names = el.tags.list.slice(0); 13 | 14 | // eslint-disable-next-line no-restricted-syntax 15 | for (const tag of names) { 16 | const copyOfEl = copyObj(el); 17 | copyOfEl.tags.list = [tag]; 18 | // eslint-disable-next-line no-param-reassign 19 | o[tag] = copyOfEl; 20 | } 21 | 22 | return o; 23 | }, {}); 24 | await fs.writeFile('./specindex.json', JSON.stringify({ version, keywordsMapping, index: dbIndex }), { encoding: 'utf-8' }); 25 | }()); 26 | -------------------------------------------------------------------------------- /nodemon.json: -------------------------------------------------------------------------------- 1 | { 2 | "ignore": [ 3 | "searchstat.json", 4 | "spec.json" 5 | ], 6 | "watch": [ 7 | "components/", 8 | "./*.js" 9 | ], 10 | "ext": "js, css", 11 | "env": { 12 | "HEADER_TIPS_HTML_CONTENT": "" 13 | } 14 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "caninclude", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "test:base": "rm -rf ./testing/output/*.png && NODE_ENV=test HEADLESS=true codeceptjs run --steps", 8 | "test:only": "npm run test:base -- --grep=@only", 9 | "test": "npm run test:base -- --grep='(?=.*)^(?!.*@skip)'", 10 | "test:ci": "rm -rf ./testing/output/*.png && NODE_ENV=test HEADLESS=true codeceptjs run --reporter mocha-junit-reporter --steps --grep='(?=.*)^(?!.*@skip)'", 11 | "spec:fix": "node specfix && node makeIndex", 12 | "crawl:check": "node speccheck.js", 13 | "crawl": "node crawl && node specfix && node makeIndex && npm run crawl:check", 14 | "start": "pm2 start ecosystem.config.js --env production --no-daemon", 15 | "dev": "nodemon ./server.js", 16 | "glitch:pack": "node ./scripts/glitch", 17 | "glitch:unpack": "unzip -o glitch_release_*.zip -d . && rm glitch_release_*.zip && refresh", 18 | "glitch:apply": "refresh", 19 | "glitch:wget": "node ./scripts/download", 20 | "gen:invite": "node ./scripts/invite --", 21 | "pm2:gen:config": "pm2 ecosystem", 22 | "pm2:stop": "pm2 stop ecosystem.config.js", 23 | "pm2:restart": "pm2 restart ecosystem.config.js --env production", 24 | "pm2:reload": "pm2 reload ecosystem.config.js --env production", 25 | "pm2:mon": "pm2 monit", 26 | "pm2:kill": "pm2 kill", 27 | "pm2:logs": "pm2 logs webapp --lines 100", 28 | "pm2:log:rotate": "pm2 install pm2-logrotate", 29 | "pm2:flush": "pm2 flush", 30 | "fix:likes:user": "node ./scripts/fixLikesUserData", 31 | "html:check": "node ./scripts/htmlcheck", 32 | "serve:lhci": "NODE_ENV=production node server.js", 33 | "codeceptjs": "codeceptjs run --steps", 34 | "codeceptjs:headless": "HEADLESS=true codeceptjs run --steps", 35 | "codeceptjs:ui": "codecept-ui --app", 36 | "cc:tools": "codeceptjs --", 37 | "cc:gt": "codeceptjs gt", 38 | "cc:def": "codeceptjs def" 39 | }, 40 | "keywords": [], 41 | "author": "", 42 | "engines": { 43 | "node": "14.x" 44 | }, 45 | "license": "ISC", 46 | "dependencies": { 47 | "archiver": "^4.0.1", 48 | "clean-css": "^4.2.3", 49 | "cookie-session": "^1.4.0", 50 | "cron": "^1.8.2", 51 | "express": "^4.17.1", 52 | "express-validator": "^6.4.1", 53 | "htm": "^3.0.4", 54 | "html-validator": "^5.1.12", 55 | "md5": "^2.2.1", 56 | "moment": "^2.29.1", 57 | "nodemon": "^2.0.3", 58 | "pm2": "^5.1.0", 59 | "preact": "^10.4.1", 60 | "preact-render-to-string": "^5.1.6", 61 | "puppeteer": "^10.4.0", 62 | "serve-favicon": "^2.5.0", 63 | "sqlite3": "^5.0.2", 64 | "uuid": "^8.0.0" 65 | }, 66 | "devDependencies": { 67 | "@codeceptjs/configure": "^0.6.2", 68 | "codeceptjs": "^3.1.3", 69 | "codeceptjs-resemblehelper": "^1.9.4", 70 | "eslint": "^7.16.0", 71 | "eslint-config-airbnb-base": "^14.2.1", 72 | "eslint-plugin-codeceptjs": "^1.3.0", 73 | "eslint-plugin-import": "^2.22.1", 74 | "expect": "^26.6.2", 75 | "faker": "^5.1.0", 76 | "mocha-junit-reporter": "^2.0.0" 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /scripts/download.js: -------------------------------------------------------------------------------- 1 | const { execSync } = require('child_process'); 2 | const urlParam = process.argv[2]; 3 | const urlParts = new URL(decodeURIComponent(urlParam)).pathname.split('/'); 4 | const [fileName] = urlParts.slice(-1); 5 | execSync(`wget -O ${fileName} ${urlParam}`); -------------------------------------------------------------------------------- /scripts/fake.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const path = require('path'); 3 | const archiver = require('archiver'); 4 | fs.mkdirSync('fakes', { recursive: true }); 5 | const output = fs.createWriteStream(path.join('fakes', 'www.zip')); 6 | const archive = archiver('zip', { 7 | zlib: { level: 9 } // Sets the compression level. 8 | }); 9 | 10 | output.on('close', () => { 11 | // eslint-disable-next-line no-console 12 | console.log(`${archive.pointer()} total bytes`); 13 | // eslint-disable-next-line no-console 14 | console.log('archiver has been finalized and the output file descriptor has closed.'); 15 | }); 16 | 17 | output.on('end', () => { 18 | // eslint-disable-next-line no-console 19 | console.log('Data has been drained'); 20 | }); 21 | 22 | archive.on('warning', (err) => { 23 | if (err.code === 'ENOENT') { 24 | // log warning 25 | // eslint-disable-next-line no-console 26 | console.warn(err); 27 | } else { 28 | // throw error 29 | throw err; 30 | } 31 | }); 32 | 33 | archive.on('error', (err) => { 34 | throw err; 35 | }); 36 | 37 | archive.pipe(output); 38 | 39 | 40 | archive.append('Hello to Chinese pentesters! :)', { name: 'package.json' }); 41 | archive.append('SECRET="Hello to Chinese pentesters! :)"', { name: '.env' }); 42 | archive.append('Hello to Chinese pentesters! :)', { name: 'nodemon.json' }); 43 | archive.append('Hello to Chinese pentesters! :)', { name: 'package-lock.json' }); 44 | archive.append('Hello to Chinese pentesters! :)', { name: 'server.js' }); 45 | archive.append('Hello to Chinese pentesters! :)', { name: 'spec.json' }); 46 | archive.append('Hello to Chinese pentesters! :)', { name: 'utils.json' }); 47 | archive.append('Hello to Chinese pentesters! :)', { name: 'crawl.js' }); 48 | 49 | archive.finalize(); -------------------------------------------------------------------------------- /scripts/fixLikesUserData.js: -------------------------------------------------------------------------------- 1 | const util = require('util'); 2 | const sqlite3 = require("sqlite3").verbose(); 3 | 4 | !async function run() { 5 | const dbConn = new sqlite3.Database("./.data/sqlite.db"); 6 | const closeAsync = util.promisify(dbConn.close).bind(dbConn); 7 | const changes = await new Promise((resolve, reject) => { 8 | dbConn.run(`UPDATE likes SET user=substr(user, 0, 37) WHERE length(user) > 36`, [], function (err) { 9 | if (err) { 10 | return reject(err); 11 | } 12 | resolve(this.changes); 13 | }); 14 | }); 15 | console.warn('changed likes:', changes); 16 | await closeAsync(); 17 | }(); -------------------------------------------------------------------------------- /scripts/glitch.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const archiver = require('archiver'); 3 | const output = fs.createWriteStream(`glitch_release_${+new Date()}.zip`); 4 | const archive = archiver('zip', { 5 | zlib: { level: 9 } // Sets the compression level. 6 | }); 7 | 8 | output.on('close', () => { 9 | // eslint-disable-next-line no-console 10 | console.log(`${archive.pointer()} total bytes`); 11 | // eslint-disable-next-line no-console 12 | console.log('archiver has been finalized and the output file descriptor has closed.'); 13 | }); 14 | 15 | output.on('end', () => { 16 | // eslint-disable-next-line no-console 17 | console.log('Data has been drained'); 18 | }); 19 | 20 | archive.on('warning', (err) => { 21 | if (err.code === 'ENOENT') { 22 | // log warning 23 | // eslint-disable-next-line no-console 24 | console.warn(err); 25 | } else { 26 | // throw error 27 | throw err; 28 | } 29 | }); 30 | 31 | archive.on('error', (err) => { 32 | throw err; 33 | }); 34 | 35 | archive.pipe(output); 36 | 37 | archive.directory('components/', 'components'); 38 | archive.directory('fakes/', 'fakes'); 39 | archive.directory('scripts/', 'scripts'); 40 | archive.file('package-lock.json', { name: 'package-lock.json' }); 41 | archive.file('package.json', { name: 'package.json' }); 42 | archive.file('server.js', { name: 'server.js' }); 43 | archive.file('utils.js', { name: 'utils.js' }); 44 | archive.file('specindex.json', { name: 'specindex.json' }); 45 | archive.file('.data/.keep', { name: '.data/.keep' }); 46 | archive.file('ecosystem.config.js', { name: 'ecosystem.config.js' }); 47 | archive.file('favicon.ico', { name: 'favicon.ico' }); 48 | 49 | archive.finalize(); -------------------------------------------------------------------------------- /scripts/htmlcheck.js: -------------------------------------------------------------------------------- 1 | const validator = require('html-validator'); 2 | (async () => { 3 | const options = [ 4 | { 5 | url: 'http://localhost:3000', 6 | validator: 'WHATWG', 7 | format: 'text' 8 | }, { 9 | url: 'http://localhost:3000/can/include/?child=article&parent=article', 10 | validator: 'WHATWG', 11 | format: 'text' 12 | }] 13 | 14 | try { 15 | const results = await Promise.all(options.map(option => validator(option))); 16 | results.forEach(result => { 17 | console.log(result); 18 | }); 19 | } catch (error) { 20 | console.error(error) 21 | } 22 | })() -------------------------------------------------------------------------------- /scripts/invite.js: -------------------------------------------------------------------------------- 1 | const util = require('util'); 2 | const md5 = require('md5'); 3 | const sqlite3 = require('sqlite3').verbose(); 4 | const { v4: uuidv4 } = require('uuid'); 5 | 6 | (async function run() { 7 | const options = process.argv.slice(2); 8 | if (!options.length) { 9 | throw new Error('No parameters'); 10 | } 11 | 12 | const dbConn = new sqlite3.Database('./.data/sqlite.db'); 13 | const runAsync = util.promisify(dbConn.run).bind(dbConn); 14 | const closeAsync = util.promisify(dbConn.close).bind(dbConn); 15 | const argsDict = Object.fromEntries(options.map((v) => v.split('='))); 16 | const key = md5(`${uuidv4()}${argsDict.role}`); 17 | await runAsync('INSERT INTO invites(key, role) VALUES(?,?)', [key, argsDict.role]); 18 | // eslint-disable-next-line no-console 19 | console.log(`/invites/${key}/apply`); 20 | await closeAsync(); 21 | }()); 22 | -------------------------------------------------------------------------------- /server.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const path = require('path'); 3 | const util = require('util'); 4 | const express = require('express'); 5 | const favicon = require('serve-favicon'); 6 | const cookieSession = require('cookie-session'); 7 | const { v4: uuidv4 } = require('uuid'); 8 | const CleanCSS = require('clean-css'); 9 | const { html } = require('htm/preact'); 10 | const url = require('url'); 11 | const renderToString = require('preact-render-to-string'); 12 | const { check, validationResult } = require('express-validator'); 13 | 14 | const AppInTest = process.env.NODE_ENV === 'test'; 15 | 16 | const { 17 | Counter, 18 | LikesManager, 19 | DbConnection, 20 | FeedbackManager, 21 | HistoryManager, 22 | InvitesManager, 23 | RecordNotFoundError, 24 | StatManager, 25 | SimpleRecommendManager, 26 | StatLikesManager, 27 | } = require('./utils'); 28 | 29 | const App = require('./components/App'); 30 | const ErrorPage = require('./components/ErrorPage'); 31 | const AdminPage = require('./components/AdminPage'); 32 | 33 | const readFile = util.promisify(fs.readFile); 34 | const FeedbackDailyLimit = Number(process.env.FEEDBACK_DAILY_LIMIT || 20); 35 | const app = express(); 36 | 37 | const dbConnection = new DbConnection(AppInTest ? ':memory:' : './.data/sqlite.db'); 38 | dbConnection.setup(); 39 | 40 | const likeManager = new LikesManager(dbConnection); 41 | const feedbackManager = new FeedbackManager(dbConnection); 42 | const counter = new Counter(dbConnection); 43 | const historyManager = new HistoryManager(dbConnection); 44 | const invitesManager = new InvitesManager(dbConnection); 45 | const statManager = new StatManager(dbConnection); 46 | const recommendManager = new SimpleRecommendManager(dbConnection); 47 | const statLikesManager = new StatLikesManager(dbConnection); 48 | 49 | function startManagers() { 50 | recommendManager.start(); 51 | statLikesManager.start(); 52 | } 53 | 54 | const port = process.env.PORT || 3000; 55 | let db = null; 56 | let css = ''; 57 | let specVersion = ''; 58 | let keywordsMapping = {}; 59 | 60 | function getKeywordHref(keyWord) { 61 | const foundObj = keywordsMapping[keyWord]; 62 | if (foundObj) { 63 | return `${foundObj.text}`; 65 | } 66 | 67 | return keyWord; 68 | } 69 | 70 | function markMatched(negative, s) { 71 | return negative && negative.has(s) ? `NO ${getKeywordHref(s)} in parent` : `${getKeywordHref(s)}`; 72 | } 73 | 74 | const messages = { 75 | makeTransparentContentWarning(parentFormatted) { 76 | return `Because the parent <${parentFormatted}/> tag has the Transparent content option and the ability to nest the tag is not fully understood.
    Please look at the nearest top element from the <${parentFormatted}/> tag (in your HTML markup) or check the Content Model of <${parentFormatted}/> tag section for more details.`; 77 | }, 78 | makeAllMessagesConditional(parentFormatted, childFormatted) { 79 | return `The parent Content Model section contains only conditional statements. Please check if the child tag <${childFormatted}/> matches the conditions of the parent <${parentFormatted}/>, and make a decision based on this.`; 80 | }, 81 | makeMatched(matched, parentFormatted, childFormatted, negative) { 82 | return `The parent tag <${parentFormatted}/> with the Content model section and the child tag <${childFormatted}/> with the Categories section have matches: ${matched.map((match) => markMatched(negative, match)).join(', ')}`; 83 | }, 84 | makeFail(matched, parentFormatted, childFormatted, negative, conditional) { 85 | const hasMatches = matched && matched.length; 86 | const hasNegative = negative && negative.size; 87 | const hasConditional = conditional && conditional.length; 88 | const showNoMatch = !hasMatches && !hasNegative && !hasConditional; 89 | 90 | return `The parent tag <${parentFormatted}/> with the Content model section and the child tag <${childFormatted}/> with the Categories ${ 91 | (hasMatches && `section have matches: ${matched.map((match) => markMatched(negative, match)).join(', ')}`) || '' 92 | } ${(hasMatches && 'and') || ''} ${(hasNegative && `have negative matches: ${[...negative].map((negativeMatch) => markMatched(negative, negativeMatch)).join(', ')}`) || ''} 93 | ${(hasConditional && 'but') || ''} ${(hasConditional && ` if it is the element from ${[...conditional].map((conditionalItem) => markMatched(negative, conditionalItem)).join(', ')} then you can include`) || ''} ${(showNoMatch && 'have no matches!') || ''}`; 94 | }, 95 | }; 96 | 97 | const ErrorsCollection = { 98 | DuplicateFeedbackMessage: 'Error: Duplicate feedback message for the current tag pair from you', 99 | FeedbackLimitExceeded: 'Error: The daily limit for sending feedback text has been reached.', 100 | ConstraintsViolation: 'Error: One of the data restrictions is violated.', 101 | }; 102 | 103 | function getMessageByError(e) { 104 | // eslint-disable-next-line no-bitwise 105 | if (~e.message.indexOf('feedbacks.key') && ~e.message.indexOf('UNIQUE constraint failed')) { 106 | return 'DuplicateFeedbackMessage'; 107 | } 108 | // eslint-disable-next-line no-bitwise 109 | if (~e.message.indexOf('FEEDBACK limit exceeded')) { 110 | return 'FeedbackLimitExceeded'; 111 | } 112 | // eslint-disable-next-line no-bitwise 113 | if (e.message.endsWith('feedbacks') && ~e.message.indexOf('CHECK constraint failed')) { 114 | return 'ConstraintsViolation'; 115 | } 116 | throw e; 117 | } 118 | 119 | function compareVersions(v1, v2) { 120 | const normalize = (p) => p.replace(/[a-z]+/g, ''); 121 | let v1Parts = v1.split('.').map((v) => Number(normalize(v))); 122 | let v2Parts = v2.split('.').map((v) => Number(normalize(v))); 123 | const diff = Math.abs(v1Parts.length - v2Parts.length); 124 | if (v1Parts.length < v2Parts.length) { 125 | v1Parts = v1Parts.concat(new Array(diff).fill(0)); 126 | } else if (v2Parts.length < v1Parts.length) { 127 | v2Parts = v2Parts.concat(new Array(diff).fill(0)); 128 | } 129 | const result = 0; 130 | for (let index = 0; index < v1Parts.length; index += 1) { 131 | const left = v1Parts[index]; 132 | const right = v2Parts[index]; 133 | if (left !== right) { 134 | return Math.sign(left - right) * 1; 135 | } 136 | } 137 | return result; 138 | } 139 | 140 | const usedOlderVersion = compareVersions('v12.14.1', process.version) === 1; 141 | 142 | function streamPage(res, htmlObj, cssStyles) { 143 | const body = renderToString(htmlObj); 144 | res.set({ 'Content-Type': 'text/html' }); 145 | res.write(` 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | Can I include a tag to a tag? Based on HTML Spec WHATWG 162 | `); 163 | res.write(` 164 | 165 | `); 166 | res.end(` 167 | 168 | 169 | ${body} 170 | 171 | `); 172 | } 173 | 174 | function createSetOfKeyWords(tag, categoryName, forceAddTagName = false) { 175 | const keyWordSet = tag.props[categoryName].reduce((o, item) => { 176 | // eslint-disable-next-line no-restricted-syntax 177 | for (const keyWord of item.keywords) { 178 | o.add(keyWord.hashText); 179 | } 180 | return o; 181 | }, new Set()); 182 | 183 | if (!keyWordSet.size || keyWordSet.has('#sectioning-root') || forceAddTagName) { 184 | // eslint-disable-next-line no-restricted-syntax 185 | for (const tagName of tag.tags.list) { 186 | keyWordSet.add(`#the-${tagName.toLowerCase()}-element`); 187 | } 188 | } 189 | return keyWordSet; 190 | } 191 | 192 | function excludeFrom(sourceSet, excludeSet) { 193 | return new Set([...sourceSet].filter((kw) => !excludeSet.has(kw))); 194 | } 195 | 196 | function canInclude(childTag, parentTag, childFormatted, parentFormatted) { 197 | const { 198 | negativeKeywords: parentNegativeKeywords, 199 | conditionalKeywords: parentConditionalKeywords, 200 | } = parentTag.props.sections.ContentModel; 201 | const { 202 | conditionalKeywords: childConditionalKeywords, 203 | } = childTag.props.sections.Categories; 204 | const parentNegativeKeywordsSet = new Set(parentNegativeKeywords); 205 | 206 | const childKeyWordsSet = excludeFrom(createSetOfKeyWords(childTag, 'Categories', true), new Set(childConditionalKeywords)); 207 | const parentKeyWordsSet = createSetOfKeyWords(parentTag, 'ContentModel'); 208 | const excludeParentNegativeKeyWords = (kw) => !parentNegativeKeywordsSet.has(kw); 209 | const includeNegativeChildKeywordInParent = (kw) => childKeyWordsSet.has(kw); 210 | 211 | const intersection = new Set([...parentKeyWordsSet].filter( 212 | excludeParentNegativeKeyWords, 213 | ).filter( 214 | includeNegativeChildKeywordInParent, 215 | )); 216 | 217 | const initialMatches = [...intersection]; 218 | 219 | const negativeIntersection = [...childKeyWordsSet].filter( 220 | (kw) => parentNegativeKeywordsSet.has(kw), 221 | ); 222 | 223 | const hasNegative = negativeIntersection.length; 224 | 225 | if (parentKeyWordsSet.has('#transparent') && !hasNegative) { 226 | return { 227 | type: 'Doubt', 228 | doubt: true, 229 | text: 'I doubt', 230 | message: messages.makeTransparentContentWarning(parentFormatted), 231 | matched: initialMatches, 232 | negative: new Set(negativeIntersection), 233 | }; 234 | } 235 | 236 | if (!intersection.size || hasNegative) { 237 | return { 238 | type: 'No', 239 | fail: true, 240 | text: 'No, you can\'t!', 241 | matched: initialMatches, 242 | negative: new Set(negativeIntersection), 243 | conditional: parentConditionalKeywords, 244 | }; 245 | } 246 | 247 | if (intersection.size) { 248 | return { 249 | type: 'Yes', 250 | success: true, 251 | text: 'Yes, you can!', 252 | matched: initialMatches, 253 | negative: new Set(parentNegativeKeywords), 254 | }; 255 | } 256 | 257 | return { unknown: true, matched: [] }; 258 | } 259 | 260 | function withCatch(cb) { 261 | return async function catchError(req, res, next) { 262 | try { 263 | await cb(req, res, next); 264 | return null; 265 | } catch (e) { 266 | return next(e); 267 | } 268 | }; 269 | } 270 | 271 | const feedbackRouter = express.Router(); 272 | feedbackRouter.post('/new', [ 273 | check('feedback').isLength({ min: 10, max: 280 }), 274 | check('parent').isLength({ min: 1 }), 275 | check('child').isLength({ min: 1 }), 276 | ], withCatch(async (req, res) => { 277 | const { feedback, parent, child } = req.body; 278 | const currentUrl = req.header('Referer') || '/'; 279 | const parentFormatted = parent.toLowerCase(); 280 | const childFormatted = child.toLowerCase(); 281 | const errors = validationResult(req); 282 | 283 | if (!errors.isEmpty()) { 284 | return res.redirect(currentUrl.href); 285 | } 286 | 287 | if (!db[parentFormatted] || !db[childFormatted]) return res.redirect(currentUrl.href); 288 | 289 | const { user } = req.session; 290 | if (user) { 291 | try { 292 | const canAdd = await feedbackManager.canAddFeedback(FeedbackDailyLimit); 293 | if (canAdd) { 294 | await feedbackManager.add({ 295 | user, text: feedback, parent: parentFormatted, child: childFormatted, 296 | }); 297 | } 298 | } catch (e) { 299 | req.session.messageKey = getMessageByError(e); 300 | return res.redirect(currentUrl); 301 | } 302 | } 303 | 304 | const parsedUrl = url.parse(currentUrl, true); 305 | const searchParams = new URLSearchParams(parsedUrl.query); 306 | searchParams.delete('feedback'); 307 | parsedUrl.search = searchParams.toString(); 308 | res.redirect(url.format(parsedUrl)); 309 | return null; 310 | })); 311 | 312 | const cookieRouter = express.Router(); 313 | cookieRouter.get('/accept', (req, res) => { 314 | if (!req.session.user) { 315 | req.session.user = uuidv4(); 316 | req.session.userAcceptCookie = true; 317 | res.redirect(req.header('Referer') || '/'); 318 | } 319 | }); 320 | 321 | const queryRouter = express.Router(); 322 | queryRouter.get('/include', [ 323 | check('parent').isLength({ min: 1 }), 324 | check('child').isLength({ min: 1 }), 325 | ], withCatch(async (req, res) => { 326 | const errors = validationResult(req); 327 | if (!errors.isEmpty()) { 328 | return res.redirect('/'); 329 | } 330 | 331 | const { user } = req.session; 332 | const tips = []; 333 | const { 334 | parent, child, like, dislike, unlike, undislike, feedback, feedbacks, swap, 335 | } = req.query; 336 | let votes = null; 337 | let parentFormatted = parent.toLowerCase().trim(); 338 | let childFormatted = child.toLowerCase().trim(); 339 | let parentTag = db[parentFormatted]; 340 | let childTag = db[childFormatted]; 341 | 342 | if (!parentTag || !childTag) return res.redirect('/'); 343 | if (swap === 'on') { 344 | [parentTag, childTag] = [childTag, parentTag]; 345 | [parentFormatted, childFormatted] = [childFormatted, parentFormatted]; 346 | } 347 | 348 | const currentUrl = `?parent=${parentFormatted}&child=${childFormatted}`; 349 | await counter.load(); 350 | 351 | if (user) { 352 | if (typeof like !== 'undefined') { 353 | await likeManager.like(user, parentFormatted, childFormatted); 354 | } else if (typeof dislike !== 'undefined') { 355 | await likeManager.dislike(user, parentFormatted, childFormatted); 356 | } else if (typeof unlike !== 'undefined') { 357 | await likeManager.unlike(user, parentFormatted, childFormatted); 358 | } else if (typeof undislike !== 'undefined') { 359 | await likeManager.undislike(user, parentFormatted, childFormatted); 360 | } 361 | } 362 | 363 | votes = await likeManager.votes(user, parentFormatted, childFormatted); 364 | 365 | const result = canInclude(childTag, parentTag, childFormatted, parentFormatted); 366 | await historyManager.register({ 367 | parent: parentFormatted, 368 | child: childFormatted, 369 | canInclude: result.type, 370 | }); 371 | 372 | if (result.doubt) { 373 | tips.push({ messages: [result.message], type: 'warning' }); 374 | } 375 | 376 | if (result.fail) { 377 | tips.push({ 378 | messages: [messages.makeFail( 379 | result.matched, 380 | parentFormatted, 381 | childFormatted, 382 | result.negative, 383 | result.conditional, 384 | )], 385 | type: 'error', 386 | }); 387 | } 388 | 389 | if (result.matched && result.matched.length && !result.fail) { 390 | tips.push({ 391 | messages: [ 392 | messages.makeMatched(result.matched, parentFormatted, childFormatted, result.negative), 393 | ], 394 | type: 'info', 395 | }); 396 | } 397 | 398 | const { messageKey } = req.session; 399 | if (messageKey) { 400 | delete req.session.messageKey; 401 | tips.push({ 402 | messages: [ErrorsCollection[messageKey]], 403 | type: 'error', 404 | }); 405 | } 406 | 407 | let canAddFeedback = true; 408 | try { 409 | await feedbackManager.canAddFeedback(FeedbackDailyLimit); 410 | } catch (e) { 411 | canAddFeedback = false; 412 | } 413 | 414 | const queryParams = { user, parent: parentFormatted, child: childFormatted }; 415 | const twoWeeksStat = await statManager.getStatCountersFor2Weeks(); 416 | const recommendResult = await recommendManager.getFromCacheOrQuery( 417 | childFormatted, 418 | parentFormatted, 419 | ); 420 | 421 | const props = { 422 | form: { parent: parentFormatted, result, child: childFormatted }, 423 | tags: [childTag, result, parentTag], 424 | tips, 425 | request: { 426 | count: counter.count, 427 | uniqCount: counter.uniqCount, 428 | url: currentUrl, 429 | user, 430 | twoWeeksStat, 431 | twoWeeksStatTotalCount: statManager.totalCount, 432 | }, 433 | specVersion, 434 | votes, 435 | userAcceptCookie: req.session.userAcceptCookie, 436 | showFeedback: typeof feedback !== 'undefined' && canAddFeedback && req.session.user, 437 | showFeedbacks: typeof feedbacks !== 'undefined', 438 | feedback: { 439 | count: await feedbackManager.countByTags(queryParams), 440 | }, 441 | feedbacks: await feedbackManager.getLastFeedbacks(queryParams), 442 | canAddFeedback, 443 | recommendResult, 444 | headerTipHtmlContent: process.env.HEADER_TIPS_HTML_CONTENT, 445 | }; 446 | 447 | streamPage(res, html`<${App} ...${props}/>`, css); 448 | })); 449 | 450 | const adminRouter = express.Router(); 451 | adminRouter.get('/feedbacks', async (req, res) => { 452 | const pageNumber = Number(req.query.page || 1); 453 | const page = await feedbackManager.getAllByPage({ page: pageNumber }); 454 | const twoWeeksStat = await statManager.getStatCountersFor2Weeks(); 455 | const request = { 456 | count: counter.count, 457 | uniqCount: counter.uniqCount, 458 | twoWeeksStat, 459 | twoWeeksStatTotalCount: statManager.totalCount, 460 | }; 461 | streamPage(res, html`<${AdminPage} ..."${page}" request="${request}"/>`, css); 462 | }); 463 | 464 | adminRouter.get('/feedbacks/:id/approve', async (req, res) => { 465 | const currentUrl = req.header('Referer') || '/'; 466 | await feedbackManager.approve({ id: req.params.id }); 467 | res.redirect(currentUrl); 468 | }); 469 | 470 | adminRouter.get('/feedbacks/:id/unapprove', async (req, res) => { 471 | const currentUrl = req.header('Referer') || '/'; 472 | await feedbackManager.unapprove({ id: req.params.id }); 473 | res.redirect(currentUrl); 474 | }); 475 | 476 | adminRouter.get('/feedbacks/:id/resolve', async (req, res) => { 477 | const currentUrl = req.header('Referer') || '/'; 478 | await feedbackManager.resolve({ id: req.params.id }); 479 | res.redirect(currentUrl); 480 | }); 481 | 482 | adminRouter.get('/feedbacks/:id/unresolve', async (req, res) => { 483 | const currentUrl = req.header('Referer') || '/'; 484 | await feedbackManager.unresolve({ id: req.params.id }); 485 | res.redirect(currentUrl); 486 | }); 487 | 488 | adminRouter.get('/feedbacks/:id/remove', async (req, res) => { 489 | const currentUrl = req.header('Referer') || '/'; 490 | await feedbackManager.remove({ id: req.params.id }); 491 | res.redirect(currentUrl); 492 | }); 493 | 494 | function checkHttps(req, res, next) { 495 | if (!req.get('X-Forwarded-Proto') || req.get('X-Forwarded-Proto').indexOf('https') !== -1) { 496 | return next(); 497 | } 498 | return res.redirect(`https://${req.hostname}${req.url}`); 499 | } 500 | 501 | async function countRequests(req, res, next) { 502 | const ip = req.headers['x-forwarded-for'] || req.connection.remoteAddress; 503 | const { 504 | like, dislike, unlike, undislike, 505 | } = req.query; 506 | if (!AppInTest) { 507 | // eslint-disable-next-line no-console 508 | console.log('', req.url, ip.split(',')[0], req.session.role || 'norole', req.session.user || 'anonymous'); 509 | } 510 | if ([like, dislike, unlike, undislike].some((x) => typeof x !== 'undefined')) return next(); 511 | await counter.register(ip.split(',')[0]); 512 | return next(); 513 | } 514 | 515 | app.use(cookieSession({ 516 | name: 'session', 517 | keys: [process.env.COOKIE_KEY || 'not-for-production-cookie-key'], 518 | signed: true, 519 | overwrite: true, 520 | // Cookie Options 521 | maxAge: 10 * 365 * 24 * 60 * 60 * 1000, // 10 year 522 | })); 523 | 524 | app.all('*', checkHttps); 525 | app.use(countRequests); 526 | app.use(favicon(path.join(__dirname, 'favicon.ico'))); 527 | app.use(withCatch((req, res, next) => { 528 | if (req.session.user && req.session.user.length > 36) { 529 | req.session.user = req.session.user.slice(0, 36); 530 | } 531 | next(); 532 | })); 533 | 534 | const inviteRouter = express.Router(); 535 | function withRoles(...roles) { 536 | const isAllowed = (role) => role && roles.includes(role.toLowerCase()); 537 | return (req, res, next) => { 538 | if (req.session.user && isAllowed(req.session.role)) { 539 | next(); 540 | } else { 541 | res.redirect('/'); 542 | } 543 | }; 544 | } 545 | inviteRouter.get('/:key/apply', withCatch(async (req, res) => { 546 | if (!req.session.user) return res.redirect('/'); 547 | const { key } = req.params; 548 | try { 549 | const record = await invitesManager.apply({ key, user: req.session.user }); 550 | req.session.role = record.role; 551 | res.redirect('/'); 552 | } catch (e) { 553 | if (e instanceof RecordNotFoundError) { 554 | return res.redirect('/'); 555 | } 556 | throw e; 557 | } 558 | })); 559 | 560 | app.get('/', withCatch(async (req, res) => { 561 | await counter.load(); 562 | const tagStats = await historyManager.getLastBy(); 563 | const twoWeeksStat = await statManager.getStatCountersFor2Weeks(); 564 | const mostLiked = await statLikesManager.getMostLiked(); 565 | const mostDisliked = await statLikesManager.getMostDisliked(); 566 | const props = { 567 | form: { parent: '', child: '' }, 568 | tags: [], 569 | tagStats, 570 | mostLiked, 571 | mostDisliked, 572 | request: { 573 | count: counter.count, 574 | uniqCount: counter.uniqCount, 575 | twoWeeksStat, 576 | twoWeeksStatTotalCount: statManager.totalCount, 577 | }, 578 | specVersion, 579 | userAcceptCookie: req.session.userAcceptCookie, 580 | showFeedback: undefined, 581 | showFeedbacks: undefined, 582 | decorationType: process.env.MAIN_PAGE_DECORATION_TYPE, 583 | logoUrl: process.env.LOGO_URL, 584 | logoAlt: process.env.LOGO_ALT, 585 | headerTipHtmlContent: process.env.HEADER_TIPS_HTML_CONTENT, 586 | }; 587 | streamPage(res, html`<${App} ...${props}/>`, css); 588 | })); 589 | 590 | app.use(express.urlencoded({ extended: true })); 591 | 592 | app.get('/robots.txt', (req, res) => { 593 | res.set({ 'Content-Type': 'text/plain' }); 594 | res.send(`User-agent: * 595 | Disallow: 596 | `); 597 | }); 598 | 599 | app.use('/static', express.static(path.join(__dirname, 'public'))); 600 | app.use('/can', queryRouter); 601 | app.use('/cookies', cookieRouter); 602 | app.use('/feedback', feedbackRouter); 603 | app.use('/admin', withRoles('admin'), adminRouter); 604 | app.use('/invites', inviteRouter); 605 | app.use(async (err, req, res, next) => { 606 | // eslint-disable-next-line no-console 607 | console.error(err.stack); 608 | next(err); 609 | }); 610 | app.use(async (err, req, res, next) => { 611 | if (req.xhr) { 612 | res.status(500).send({ error: 'Something failed!' }); 613 | } else { 614 | next(err); 615 | } 616 | }); 617 | app.use(async (err, req, res, next) => { 618 | if (!err) { 619 | return next(); 620 | } 621 | res.status(500); 622 | const refererUrl = req.header('Referer') || '/'; 623 | const twoWeeksStat = await statManager.getStatCountersFor2Weeks(); 624 | const request = { 625 | count: counter.count, 626 | uniqCount: counter.uniqCount, 627 | url: refererUrl, 628 | twoWeeksStat, 629 | twoWeeksStatTotalCount: statManager.totalCount, 630 | }; 631 | 632 | return streamPage(res, html`<${ErrorPage} request="${request}"/>`, css); 633 | }); 634 | 635 | function startServer(appPort) { 636 | return new Promise((resolve) => { 637 | const server = app.listen(appPort, async () => { 638 | try { 639 | startManagers(); 640 | // eslint-disable-next-line no-console 641 | console.warn('usedOlderVersion:', usedOlderVersion, 'current version:', process.version); 642 | // eslint-disable-next-line no-console 643 | console.warn('[i] Begin read database'); 644 | css = await readFile('./components/App.css', { encoding: 'utf8' }); 645 | const { styles } = new CleanCSS().minify(css); 646 | css = styles; 647 | const specIndex = await readFile('./specindex.json', { encoding: 'utf8' }).then((c) => JSON.parse(c)); 648 | specVersion = specIndex.version; 649 | keywordsMapping = specIndex.keywordsMapping; 650 | db = specIndex.index; 651 | // eslint-disable-next-line no-console 652 | console.warn('[i] End of reading database'); 653 | // eslint-disable-next-line no-console 654 | console.warn('[i] Begin read searchstat.json'); 655 | await counter.load(); 656 | // eslint-disable-next-line no-console 657 | console.warn('[i] End of reading searchstat.json'); 658 | // eslint-disable-next-line no-console 659 | console.log(`[caninclude app] listening at http://localhost:${port}`); 660 | resolve(server); 661 | } catch (e) { 662 | // eslint-disable-next-line no-console 663 | console.warn(e.message); 664 | } 665 | }); 666 | }); 667 | } 668 | 669 | async function park() { 670 | if (dbConnection) { 671 | try { 672 | await dbConnection.close(); 673 | // eslint-disable-next-line no-console 674 | console.warn('Database connection closed successfully'); 675 | process.exit(0); 676 | } catch (err) { 677 | // eslint-disable-next-line no-console 678 | console.warn('Database connection closed with error', err); 679 | process.exit(1); 680 | } 681 | } 682 | } 683 | 684 | process.on('SIGINT', park); 685 | 686 | if (require.main === module) { 687 | startServer(port); 688 | } else { 689 | module.exports = { 690 | async start(...args) { 691 | const server = await startServer(...args); 692 | return async () => { 693 | await park(); 694 | server.close(); 695 | }; 696 | }, 697 | resetDb() { 698 | return dbConnection.reset(); 699 | }, 700 | closeDb() { 701 | return dbConnection.close(); 702 | }, 703 | getConnection: () => dbConnection, 704 | setEnv(name, value) { 705 | process.env[name] = value; 706 | }, 707 | }; 708 | } 709 | -------------------------------------------------------------------------------- /speccheck.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-restricted-syntax */ 2 | // eslint-disable-next-line import/no-extraneous-dependencies 3 | const expect = require('expect'); 4 | const util = require('util'); 5 | const fs = require('fs'); 6 | 7 | const readFile = util.promisify(fs.readFile); 8 | 9 | function normalize(text) { 10 | return text.replace(/(\s+|\n)/gi, ' '); 11 | } 12 | 13 | function joinElements(list) { 14 | return normalize(list.map((v) => { 15 | if (typeof v === 'string') { 16 | return v; 17 | } 18 | return v.text; 19 | }).join('')); 20 | } 21 | 22 | (async function start() { 23 | try { 24 | const specContent = await readFile('./specindex.json'); 25 | const specJson = JSON.parse(specContent); 26 | 27 | // eslint-disable-next-line no-restricted-syntax 28 | for (const tag of Object.keys(specJson.index)) { 29 | const { props } = specJson.index[tag]; 30 | for (const section of ['Categories', 'ContextsInWhichThisElementCanBeUsed', 'ContentModel']) { 31 | props[section].forEach((o) => { 32 | try { 33 | expect(normalize(o.textContent)).toStrictEqual(joinElements(o.elements)); 34 | } catch (e) { 35 | throw new Error(`The tag: "${tag}" has a problem in the section "${section}": ${e}`); 36 | } 37 | }); 38 | } 39 | } 40 | // eslint-disable-next-line no-console 41 | console.log('[speccheck.js] [OK] specindex healthy!'); 42 | process.exit(0); 43 | } catch (e) { 44 | // eslint-disable-next-line no-console 45 | console.error('[speccheck.js] [FAIL] ', e); 46 | process.exit(1); 47 | } 48 | }()); 49 | -------------------------------------------------------------------------------- /specfix.js: -------------------------------------------------------------------------------- 1 | const util = require('util'); 2 | const fs = require('fs'); 3 | 4 | const readFile = util.promisify(fs.readFile); 5 | const writeFile = util.promisify(fs.writeFile); 6 | const keywordsMapping = {}; 7 | 8 | (async function start() { 9 | const specContent = await readFile('./spec.json'); 10 | const specJson = JSON.parse(specContent); 11 | 12 | function process(section) { 13 | const negativeKeywords = []; 14 | const conditionalKeywords = []; 15 | 16 | // eslint-disable-next-line no-restricted-syntax 17 | for (const obj of section) { 18 | let prevNegative = false; 19 | let prevCondition = false; 20 | // eslint-disable-next-line no-restricted-syntax 21 | for (const element of obj.elements) { 22 | if (typeof element === 'string') { 23 | const canContinue = /,/.test(element); 24 | const ifThenCondition = /:/.test(element); 25 | const hasOrAnd = /(\b(and|or)\b)/.test(element); 26 | prevNegative = /(\b([Nn]o|[Nn]ot)\b(?! (more than one)))/.test(element) || (prevNegative && (hasOrAnd || canContinue)); 27 | prevCondition = /(\b([Ii]f|[Uu]nless)\b)/.test(element) || (prevCondition && (hasOrAnd || canContinue || ifThenCondition)); 28 | } else { 29 | keywordsMapping[element.hashText] = element; 30 | if (prevNegative) { 31 | negativeKeywords.push(element.hashText); 32 | } 33 | if (prevCondition) { 34 | conditionalKeywords.push(element.hashText); 35 | } 36 | } 37 | } 38 | } 39 | 40 | return { 41 | negativeKeywords: [...new Set(negativeKeywords)].map((item) => item.toLowerCase()), 42 | conditionalKeywords: [...new Set(conditionalKeywords)].map((item) => item.toLowerCase()), 43 | }; 44 | } 45 | 46 | const processed = specJson.result.map((tag) => { 47 | // eslint-disable-next-line no-param-reassign 48 | tag.props.sections = tag.props.sections || {}; 49 | let sectionProps = process(tag.props.Categories); 50 | // eslint-disable-next-line no-param-reassign 51 | tag.props.sections.Categories = sectionProps; 52 | 53 | sectionProps = process(tag.props.ContentModel); 54 | // eslint-disable-next-line no-param-reassign 55 | tag.props.sections.ContentModel = sectionProps; 56 | return tag; 57 | }); 58 | 59 | await writeFile('./spec_fixed.json', JSON.stringify({ version: specJson.version, keywordsMapping, result: processed }, ' ', 2)); 60 | }()); 61 | -------------------------------------------------------------------------------- /steps.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | type steps_file = typeof import('./testing/steps/steps_file.js'); 3 | type MainPage = typeof import('./testing/pages/Main.js'); 4 | type CommonPage = typeof import('./testing/pages/Common.js'); 5 | type DetailPage = typeof import('./testing/pages/Detail.js'); 6 | type DataTables = typeof import('./testing/datatables/index.js'); 7 | type DbHelper = import('./testing/helpers/dbHelper.js'); 8 | type ResembleHelper = import('codeceptjs-resemblehelper'); 9 | 10 | declare namespace CodeceptJS { 11 | interface SupportObject { I: I, current: any, MainPage: MainPage, CommonPage: CommonPage, DetailPage: DetailPage, DataTables: DataTables } 12 | interface Methods extends Puppeteer, DbHelper, ResembleHelper {} 13 | interface I extends ReturnType, WithTranslation, WithTranslation {} 14 | namespace Translation { 15 | interface Actions {} 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /testing/datatables/index.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | const normalizedPath = path.join(__dirname, 'tables'); 4 | 5 | const allModules = {}; 6 | 7 | require('fs').readdirSync(normalizedPath).forEach((file) => { 8 | // eslint-disable-next-line import/no-dynamic-require, global-require 9 | allModules[path.basename(file, path.extname(file))] = require(`./tables/${file}`); 10 | }); 11 | 12 | module.exports = allModules; 13 | -------------------------------------------------------------------------------- /testing/datatables/tables/decoration.js: -------------------------------------------------------------------------------- 1 | const { DataTableObject } = require('../types'); 2 | 3 | module.exports = { 4 | mainPageDecoration: new DataTableObject({ 5 | 'Main page decoration is turned on': { 6 | envName: 'MAIN_PAGE_DECORATION_TYPE', 7 | envValue: 'NY_LIGHT_RIBBON', 8 | shouldSeeDecoration: true, 9 | }, 10 | 'Main page decoration is turned off': { 11 | envName: 'MAIN_PAGE_DECORATION_TYPE', 12 | envValue: '', 13 | shouldSeeDecoration: false, 14 | }, 15 | }), 16 | }; 17 | -------------------------------------------------------------------------------- /testing/datatables/tables/history.js: -------------------------------------------------------------------------------- 1 | const moment = require('moment'); 2 | const { DataTableObject } = require('../types'); 3 | 4 | module.exports = { 5 | recommendationsByParentTag: new DataTableObject({ 6 | 'Recommends most viewed at the same created date': { 7 | historyItems: [ 8 | { 9 | child: 'span', parent: 'h1', count: 1, canInclude: 'yes', created: moment().format('YYYY-MM-DD'), 10 | }, 11 | { 12 | child: 'span', parent: 'h2', count: 2, canInclude: 'yes', created: moment().format('YYYY-MM-DD'), 13 | }, 14 | ], 15 | recommendationText: 'Also looking at this pair of tags inside

    ', 16 | }, 17 | 'Recommends most viewd tag but older': { 18 | historyItems: [ 19 | { 20 | child: 'span', parent: 'h1', count: 2, canInclude: 'yes', created: moment().add(-1).format('YYYY-MM-DD'), 21 | }, 22 | { 23 | child: 'span', parent: 'h2', count: 1, canInclude: 'yes', created: moment().format('YYYY-MM-DD'), 24 | }, 25 | ], 26 | recommendationText: 'Also looking at this pair of tags inside

    ', 27 | }, 28 | }), 29 | tagPairs: new DataTableObject({ 30 | 'img inside button': { 31 | child: 'img', 32 | parent: 'button', 33 | resultSectionImg: 'result_yes.png', 34 | }, 35 | 'a inside a': { 36 | child: 'a', 37 | parent: 'a', 38 | resultSectionImg: 'result_no.png', 39 | }, 40 | 'a inside label': { 41 | child: 'a', 42 | parent: 'label', 43 | resultSectionImg: 'result_yes.png', 44 | }, 45 | 'img inside a': { 46 | child: 'img', 47 | parent: 'a', 48 | resultSectionImg: 'result_doubt.png', 49 | }, 50 | 'label inside a': { 51 | child: 'label', 52 | parent: 'a', 53 | resultSectionImg: 'result_no.png', 54 | }, 55 | }), 56 | }; 57 | -------------------------------------------------------------------------------- /testing/datatables/tables/likes.js: -------------------------------------------------------------------------------- 1 | const { DataTableObject } = require('../types'); 2 | 3 | module.exports = { 4 | likesTable: new DataTableObject({ 5 | 'One like': { 6 | actualRows: [ 7 | { parent: 'div', child: 'span', type: 'like' }, 8 | ], 9 | expectedCountRows: 1, 10 | expectedRows: [ 11 | ['span', 'div', '1', 'result'], 12 | ], 13 | }, 14 | 'More than one like': { 15 | actualRows: [ 16 | { parent: 'div', child: 'span', type: 'like' }, 17 | { parent: 'div', child: 'span', type: 'like' }, 18 | { parent: 'div', child: 'span', type: 'like' }, 19 | ], 20 | expectedCountRows: 1, 21 | expectedRows: [ 22 | ['span', 'div', '3', 'result'], 23 | ], 24 | }, 25 | 'Equal count of likes and dislikes': { 26 | actualRows: [ 27 | { parent: 'div', child: 'span', type: 'like' }, 28 | { parent: 'div', child: 'span', type: 'dislike' }, 29 | ], 30 | expectedCountRows: 0, 31 | expectedRows: [], 32 | }, 33 | 'No likes': { 34 | actualRows: [ 35 | { parent: 'div', child: 'span', type: 'dislike' }, 36 | ], 37 | expectedCountRows: 0, 38 | expectedRows: [], 39 | }, 40 | }), 41 | 42 | dislikesTable: new DataTableObject({ 43 | 'One dislike': { 44 | actualRows: [ 45 | { parent: 'div', child: 'span', type: 'dislike' }, 46 | ], 47 | expectedCountRows: 1, 48 | expectedRows: [ 49 | ['span', 'div', '1', 'result'], 50 | ], 51 | }, 52 | 'More than one dislike': { 53 | actualRows: [ 54 | { parent: 'div', child: 'span', type: 'dislike' }, 55 | { parent: 'div', child: 'span', type: 'dislike' }, 56 | { parent: 'div', child: 'span', type: 'dislike' }, 57 | ], 58 | expectedCountRows: 1, 59 | expectedRows: [ 60 | ['span', 'div', '3', 'result'], 61 | ], 62 | }, 63 | 'Equal count of likes and dislikes': { 64 | actualRows: [ 65 | { parent: 'div', child: 'span', type: 'like' }, 66 | { parent: 'div', child: 'span', type: 'dislike' }, 67 | ], 68 | expectedCountRows: 0, 69 | expectedRows: [], 70 | }, 71 | 'No dislikes': { 72 | actualRows: [ 73 | { parent: 'div', child: 'span', type: 'like' }, 74 | ], 75 | expectedCountRows: 0, 76 | expectedRows: [], 77 | }, 78 | }), 79 | }; 80 | -------------------------------------------------------------------------------- /testing/datatables/types.js: -------------------------------------------------------------------------------- 1 | class DataTableObject extends global.DataTable { 2 | constructor(obj) { 3 | const keys = Object.keys(obj); 4 | const firstItemKeys = Object.keys(obj[keys[0]]); 5 | super(firstItemKeys); 6 | this.table = obj; 7 | } 8 | 9 | add(name) { 10 | super.add(Object.values(this.table[name])); 11 | } 12 | 13 | xadd(name) { 14 | super.xadd(Object.values(this.table[name])); 15 | } 16 | } 17 | 18 | module.exports = { 19 | DataTableObject, 20 | }; 21 | -------------------------------------------------------------------------------- /testing/helpers/dbHelper.js: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line import/no-extraneous-dependencies 2 | const Helper = require('@codeceptjs/helper'); 3 | const util = require('util'); 4 | // eslint-disable-next-line import/no-extraneous-dependencies 5 | const faker = require('faker'); 6 | 7 | const { getConnection } = require('../../server'); 8 | 9 | const htmlTags = ['html', 'head', 'title', 'base', 'link', 'meta', 10 | 'style', 'body', 'article', 'section', 'nav', 11 | 'aside', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 12 | 'hgroup', 'header', 'footer', 'address', 'p', 13 | 'hr', 'pre', 'blockquote', 'ol', 'ul', 'menu', 14 | 'li', 'dl', 'dt', 'dd', 'figure', 'figcaption', 15 | 'main', 'div', 'a', 'em', 'strong', 'small', 's', 16 | 'cite', 'q', 'dfn', 'abbr', 'ruby', 'rt', 'rp', 17 | 'data', 'time', 'code', 'var', 'samp', 'kbd', 18 | 'sub', 'sup', 'i', 'b', 'u', 'mark', 'bdi', 19 | 'bdo', 'span', 'br', 'wbr', 'ins', 'del', 20 | 'picture', 'source', 'img', 'iframe', 'embed', 21 | 'object', 'param', 'video', 'audio', 'track', 22 | 'map', 'area', 'table', 'caption', 'colgroup', 23 | 'col', 'tbody', 'thead', 'tfoot', 'tr', 'td', 24 | 'th', 'form', 'label', 'input', 'button', 25 | 'select', 'datalist', 'optgroup', 'option', 26 | 'textarea', 'output', 'progress', 'meter', 27 | 'fieldset', 'legend', 'details', 'summary', 28 | 'dialog', 'script', 'noscript', 'template', 29 | 'slot', 'canvas']; 30 | 31 | function promisifyConnection(conn) { 32 | return { 33 | get: util.promisify(conn.database.get).bind(conn.database), 34 | run(sql, params = []) { 35 | return new Promise((resolve, reject) => { 36 | conn.database.run(sql, params, function runCallback(err) { 37 | if (err) return reject(err); 38 | return resolve(this.lastID); 39 | }); 40 | }); 41 | }, 42 | }; 43 | } 44 | 45 | class DbHelper extends Helper { 46 | // before/after hooks 47 | /** 48 | * @protected 49 | */ 50 | // eslint-disable-next-line no-underscore-dangle, class-methods-use-this 51 | _before() { 52 | // remove if not used 53 | } 54 | 55 | /** 56 | * @protected 57 | */ 58 | // eslint-disable-next-line no-underscore-dangle, class-methods-use-this 59 | _after() { 60 | // remove if not used 61 | } 62 | 63 | // add custom methods here 64 | // If you need to access other helpers 65 | // use: this.helpers['helperName'] 66 | 67 | // eslint-disable-next-line class-methods-use-this 68 | async haveHistoryItemInDb({ 69 | parent, child, canInclude, count = 1, created = Date.now(), 70 | } = {}) { 71 | const con = promisifyConnection(getConnection()); 72 | const lastId = await con.run( 73 | 'INSERT INTO history(parent, child, canInclude, count, created) VALUES(?,?,?,?,?)', 74 | [parent, child, canInclude.toLowerCase(), count, created], 75 | ); 76 | const row = await con.get('SELECT * FROM history WHERE id = ?', [lastId]); 77 | return row; 78 | } 79 | 80 | // eslint-disable-next-line class-methods-use-this 81 | async haveLikeItemInDb({ 82 | parent = faker.random.arrayElement(htmlTags), 83 | child = faker.random.arrayElement(htmlTags), 84 | user = faker.random.uuid(), 85 | type = faker.random.arrayElement(['like', 'dislike', 'unknown']), 86 | } = {}) { 87 | const con = promisifyConnection(getConnection()); 88 | const lastId = await con.run( 89 | 'INSERT INTO likes(user, parent, child, type) VALUES(?,?,?,?)', 90 | [user, parent, child, type], 91 | ); 92 | const row = await con.get('SELECT * FROM likes WHERE id = ?', [lastId]); 93 | return row; 94 | } 95 | 96 | async haveHistoryItemsInDb(items) { 97 | const results = []; 98 | // eslint-disable-next-line no-restricted-syntax 99 | for (const item of items) { 100 | // eslint-disable-next-line no-await-in-loop 101 | const row = await this.haveHistoryItemInDb(item); 102 | results.push(row); 103 | } 104 | return results; 105 | } 106 | 107 | async haveLikeItemsInDb(items) { 108 | const results = []; 109 | // eslint-disable-next-line no-restricted-syntax 110 | for (const item of items) { 111 | // eslint-disable-next-line no-await-in-loop 112 | const row = await this.haveLikeItemInDb(item); 113 | results.push(row); 114 | } 115 | return results; 116 | } 117 | 118 | async haveANumberOfHistoryItemsInDb(count) { 119 | const results = []; 120 | for (let i = 1; i <= count; i += 1) { 121 | const item = { 122 | parent: faker.random.arrayElement(htmlTags), 123 | child: faker.random.arrayElement(htmlTags), 124 | canInclude: faker.random.arrayElement(['yes', 'no', 'doubt']), 125 | count: faker.datatype.number({ min: 1, max: 100 }), 126 | }; 127 | // eslint-disable-next-line no-await-in-loop 128 | const row = await this.haveHistoryItemInDb(item); 129 | results.push(row); 130 | } 131 | return results; 132 | } 133 | } 134 | 135 | module.exports = DbHelper; 136 | -------------------------------------------------------------------------------- /testing/helpers/envHelper.js: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line import/no-extraneous-dependencies 2 | const Helper = require('@codeceptjs/helper'); 3 | const { setEnv } = require('../../server'); 4 | 5 | class EnvHelper extends Helper { 6 | // before/after hooks 7 | /** 8 | * @protected 9 | */ 10 | // eslint-disable-next-line no-underscore-dangle, class-methods-use-this 11 | _before() { 12 | // remove if not used 13 | } 14 | 15 | /** 16 | * @protected 17 | */ 18 | // eslint-disable-next-line no-underscore-dangle, class-methods-use-this 19 | _after() { 20 | // remove if not used 21 | } 22 | 23 | // add custom methods here 24 | // If you need to access other helpers 25 | // use: this.helpers['helperName'] 26 | 27 | // eslint-disable-next-line class-methods-use-this 28 | async amSettingAnEnvVariable(name, value) { 29 | setEnv(name, value); 30 | } 31 | } 32 | 33 | module.exports = EnvHelper; 34 | -------------------------------------------------------------------------------- /testing/output/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CyberLight/caninclude/cc9188c06aef32a7c3abaf123957096d22d09390/testing/output/.keep -------------------------------------------------------------------------------- /testing/pages/Common.js: -------------------------------------------------------------------------------- 1 | // const { I } = inject(); 2 | 3 | const CookieConsentDialogLocator = locate('div[class="cookie-consent"]'); 4 | 5 | module.exports = { 6 | helpers: { 7 | detailUrl(item) { 8 | return `/can/include/?child=${item.child}&parent=${item.parent}`; 9 | }, 10 | }, 11 | buttons: { 12 | acceptCookieContent: locate('a[class="cookie-consent__accept-button"]').inside(CookieConsentDialogLocator), 13 | }, 14 | dialogs: { 15 | cookieConsent: CookieConsentDialogLocator.withChild( 16 | locate('a[class="cookie-consent__accept-button"]').withText('Accept'), 17 | ), 18 | }, 19 | // insert your locators and methods here 20 | }; 21 | -------------------------------------------------------------------------------- /testing/pages/Detail.js: -------------------------------------------------------------------------------- 1 | const { I } = inject(); 2 | 3 | module.exports = { 4 | // insert your locators and methods here 5 | labels: { 6 | head: locate('h2.head').inside('header'), 7 | recommendText: locate('.recommends__text').inside('.recommends'), 8 | }, 9 | sections: { 10 | result: locate('div.section-result__container'), 11 | left: locate('section.tag__section').at(1), 12 | }, 13 | amOnPage(child, parent) { 14 | I.amOnPage(`/can/include?child=${child}&parent=${parent}`); 15 | I.waitForVisible(this.labels.head); 16 | I.seeTextEquals('Can I include', this.labels.head); 17 | }, 18 | 19 | seeRecommendationText(text) { 20 | I.seeTextEquals(text, this.labels.recommendText); 21 | }, 22 | 23 | seeTextInTheCategorySection(text, location = 'left') { 24 | I.seeTextEquals(text, this.sections[location].find(locate('div.tag__items')).at(1)); 25 | }, 26 | }; 27 | -------------------------------------------------------------------------------- /testing/pages/Main.js: -------------------------------------------------------------------------------- 1 | const { I } = inject(); 2 | const SearchForm = locate('form#search'); 3 | const QuickResultsSection = locate('table.quick-results__table').after( 4 | locate('.quick-results__header').withText('Quick Results'), 5 | ); 6 | const MostLikedSection = locate('table.quick-results__table').after( 7 | locate('.quick-results__header').withText('Most liked'), 8 | ); 9 | const MostDislikedSection = locate('table.quick-results__table').after( 10 | locate('.quick-results__header').withText('Most disliked'), 11 | ); 12 | 13 | module.exports = { 14 | labels: { 15 | head: locate('h2.head').inside('form#search'), 16 | quickResults: locate('h2.quick-results__header').inside('section.about__quick-results'), 17 | swap: locate('label.swap').inside(SearchForm), 18 | }, 19 | tables: { 20 | head: locate('tr.table__head').inside('table.quick-results__table'), 21 | rows: locate('tr.table__row').inside('table.quick-results__table'), 22 | quickResultsRows: locate('tr.table__row').inside(QuickResultsSection), 23 | quickResultsHead: locate('tr.table__head').inside(QuickResultsSection), 24 | mostLikedRows: locate('tr.table__row').inside(MostLikedSection), 25 | mostDislikedRows: locate('tr.table__row').inside(MostDislikedSection), 26 | row(index) { 27 | return locate(this.rows).at(index); 28 | }, 29 | resultLink(index) { 30 | return locate('a.table__link').at(index); 31 | }, 32 | }, 33 | decoration: { 34 | lightRope: locate('ul.lightrope'), 35 | }, 36 | counters: { 37 | requests: locate('p.counter').inside('footer'), 38 | }, 39 | inputs: { 40 | child: locate('input[name="child"]').inside(SearchForm), 41 | parent: locate('input[name="parent"]').inside(SearchForm), 42 | swap: locate('input[name="swap"]').inside(SearchForm), 43 | }, 44 | buttons: { 45 | submit: locate('button[type="submit"]').inside(SearchForm), 46 | }, 47 | amOnPage() { 48 | I.amOnPage('/'); 49 | I.waitForVisible(this.labels.head); 50 | I.seeTextEquals('Can I Include*', this.labels.head); 51 | }, 52 | fillForm(options) { 53 | const { swap, ...other } = options; 54 | I.fillField(this.inputs.child, other.child); 55 | I.seeInField(this.inputs.child, other.child); 56 | I.fillField(this.inputs.parent, other.parent); 57 | I.seeInField(this.inputs.parent, other.parent); 58 | if (swap) { 59 | I.click(this.labels.swap); 60 | } 61 | }, 62 | helpers: { 63 | getLastRow(rows, addValues = []) { 64 | return rows.slice(-1).map((r) => ( 65 | [r.child, r.parent, r.canInclude, String(r.count), ...addValues] 66 | )); 67 | }, 68 | }, 69 | // insert your locators and methods here 70 | }; 71 | -------------------------------------------------------------------------------- /testing/plugins/dbPlugin.js: -------------------------------------------------------------------------------- 1 | // populate database for slow tests 2 | // eslint-disable-next-line import/no-extraneous-dependencies 3 | const { event, recorder, output } = require('codeceptjs'); 4 | const { resetDb } = require('../../server'); 5 | 6 | const TiggerOnTag = '@db'; 7 | 8 | module.exports = () => { 9 | event.dispatcher.on(event.test.before, (test) => { 10 | if (test.tags.indexOf(TiggerOnTag) >= 0) { 11 | recorder.add('dump db', async () => { 12 | output.print('[ ] reset db connection'); 13 | await resetDb(); 14 | output.print('[ok] reset db connection'); 15 | }); 16 | } 17 | }); 18 | }; 19 | -------------------------------------------------------------------------------- /testing/reports/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CyberLight/caninclude/cc9188c06aef32a7c3abaf123957096d22d09390/testing/reports/.keep -------------------------------------------------------------------------------- /testing/screenshots/base/b_s_yes_history_single_row.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CyberLight/caninclude/cc9188c06aef32a7c3abaf123957096d22d09390/testing/screenshots/base/b_s_yes_history_single_row.png -------------------------------------------------------------------------------- /testing/screenshots/base/img_a_doubt_single_history_row.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CyberLight/caninclude/cc9188c06aef32a7c3abaf123957096d22d09390/testing/screenshots/base/img_a_doubt_single_history_row.png -------------------------------------------------------------------------------- /testing/screenshots/base/p_ul_no_history_single_row.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CyberLight/caninclude/cc9188c06aef32a7c3abaf123957096d22d09390/testing/screenshots/base/p_ul_no_history_single_row.png -------------------------------------------------------------------------------- /testing/screenshots/base/result_doubt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CyberLight/caninclude/cc9188c06aef32a7c3abaf123957096d22d09390/testing/screenshots/base/result_doubt.png -------------------------------------------------------------------------------- /testing/screenshots/base/result_no.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CyberLight/caninclude/cc9188c06aef32a7c3abaf123957096d22d09390/testing/screenshots/base/result_no.png -------------------------------------------------------------------------------- /testing/screenshots/base/result_yes.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CyberLight/caninclude/cc9188c06aef32a7c3abaf123957096d22d09390/testing/screenshots/base/result_yes.png -------------------------------------------------------------------------------- /testing/screenshots/diff/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CyberLight/caninclude/cc9188c06aef32a7c3abaf123957096d22d09390/testing/screenshots/diff/.keep -------------------------------------------------------------------------------- /testing/scripts/testsBootstrap.js: -------------------------------------------------------------------------------- 1 | const { start } = require('../../server'); 2 | 3 | let stop = null; 4 | 5 | module.exports = { 6 | // adding bootstrap/teardown 7 | async bootstrap() { 8 | stop = await start(3000, true); 9 | }, 10 | async teardown() { 11 | if (typeof stop === 'function') { 12 | await stop(); 13 | } 14 | }, 15 | // ... 16 | // other config options 17 | }; 18 | -------------------------------------------------------------------------------- /testing/steps/steps_file.js: -------------------------------------------------------------------------------- 1 | // in this file you can append custom step methods to 'I' object 2 | // eslint-disable-next-line import/no-extraneous-dependencies 3 | const expect = require('expect'); 4 | 5 | module.exports = function customSteps() { 6 | // eslint-disable-next-line no-undef 7 | return actor({ 8 | 9 | // Define custom steps here, use 'this' to access default methods of I. 10 | // It is recommended to place a general 'login' function here. 11 | async checkTableColumnNames(headLocator, expected) { 12 | const headColumnNames = await this.grabTextFromAll(headLocator).then((row) => row.map((s) => s.split('\t'))).then((result) => result[0]); 13 | expect(headColumnNames).toStrictEqual(expected); 14 | }, 15 | async checkRequestsCounterValues(locator, expected) { 16 | const locatorText = await this.grabTextFrom(locator); 17 | expect(locatorText).toStrictEqual(expected); 18 | }, 19 | async checkTableRow(rowLocator, expected) { 20 | const actualRowValues = await this.grabTextFromAll(rowLocator).then((row) => row.map((s) => s.split('\t'))); 21 | expect(actualRowValues).toStrictEqual(expected); 22 | }, 23 | async checkAllLabels(rowLocator, expected) { 24 | const actualRowValues = await this.grabTextFromAll(rowLocator); 25 | expect(actualRowValues).toStrictEqual(expected); 26 | }, 27 | async checkSessionCookieContent() { 28 | this.seeCookie('session.sig'); 29 | this.seeCookie('session'); 30 | const sessionCookie = await this.grabCookie('session'); 31 | const jsonString = Buffer.from(sessionCookie.value, 'base64').toString(); 32 | const sessionObj = JSON.parse(jsonString); 33 | expect(sessionObj).toEqual(expect.objectContaining({ 34 | user: expect.stringMatching(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/), 35 | userAcceptCookie: expect.any(Boolean), 36 | })); 37 | }, 38 | }); 39 | }; 40 | -------------------------------------------------------------------------------- /testing/tests/bugSpecParseEmTagContentSkipped_test.js: -------------------------------------------------------------------------------- 1 | const { DetailPage } = inject(); 2 | 3 | Feature('#77 bug spec parse em tag content skipped'); 4 | 5 | Scenario('tag EM included as text for Input tag sections', () => { 6 | DetailPage.amOnPage('input', 'button'); 7 | DetailPage.seeTextInTheCategorySection( 8 | `Categories 9 | 10 | Flow content. 11 | Phrasing content. 12 | If the type attribute is not in the Hidden state: Interactive content. 13 | If the type attribute is not in the Hidden state: Listed, labelable, submittable, resettable, and autocapitalize-inheriting form-associated element. 14 | If the type attribute is in the Hidden state: Listed, submittable, resettable, and autocapitalize-inheriting form-associated element. 15 | If the type attribute is not in the Hidden state: Palpable content.`, 16 | ); 17 | }); 18 | -------------------------------------------------------------------------------- /testing/tests/canincludeTags_test.js: -------------------------------------------------------------------------------- 1 | const { DetailPage, DataTables } = inject(); 2 | 3 | Feature('canincludeTags'); 4 | 5 | const dataTable1 = DataTables.history.tagPairs; 6 | dataTable1.add('img inside button'); 7 | dataTable1.add('a inside a'); 8 | dataTable1.add('a inside label'); 9 | dataTable1.add('img inside a'); 10 | dataTable1.add('label inside a'); 11 | 12 | Data(dataTable1) 13 | .Scenario('Check posibility to include one tag to another', ({ I, current }) => { 14 | const { child, parent, resultSectionImg } = current; 15 | DetailPage.amOnPage(child, parent); 16 | I.saveElementScreenshot(DetailPage.sections.result, resultSectionImg); 17 | I.seeVisualDiffForElement(DetailPage.sections.result, resultSectionImg, { 18 | tolerance: 0, 19 | prepareBaseImage: false, 20 | scaleToSameSize: true, 21 | ignore: 'antialiasing', 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /testing/tests/cookiesConsent_test.js: -------------------------------------------------------------------------------- 1 | const { MainPage, CommonPage } = inject(); 2 | 3 | Feature('Cookies consent dialog'); 4 | 5 | Scenario('Show dialog for users who have not clicked the "accept" button', ({ I }) => { 6 | MainPage.amOnPage(); 7 | I.dontSeeCookie(); 8 | I.refreshPage(); 9 | I.seeElementInDOM(CommonPage.dialogs.cookieConsent); 10 | }); 11 | 12 | Scenario('Don\'t show dialog for users who clicked the "accept" button', async ({ I }) => { 13 | MainPage.amOnPage(); 14 | I.dontSeeCookie(); 15 | I.click(CommonPage.buttons.acceptCookieContent); 16 | I.dontSeeElementInDOM(CommonPage.dialogs.cookieConsent); 17 | await I.checkSessionCookieContent(); 18 | }); 19 | -------------------------------------------------------------------------------- /testing/tests/forms_test.js: -------------------------------------------------------------------------------- 1 | const { MainPage } = inject(); 2 | 3 | Feature('forms'); 4 | 5 | const formDataTable = new DataTable(['child', 'swap', 'parent', 'expectedUrl']); 6 | formDataTable.add(['div', false, 'section', '/can/include/?child=div&parent=section']); 7 | formDataTable.add(['div', true, 'section', '/can/include/?child=div&swap=on&parent=section']); 8 | 9 | Data(formDataTable) 10 | .Scenario('Main page sending form with swap and without', ({ I, current }) => { 11 | MainPage.amOnPage(); 12 | MainPage.fillForm({ child: current.child, parent: current.parent, swap: current.swap }); 13 | I.click(MainPage.buttons.submit); 14 | I.seeInCurrentUrl(current.expectedUrl); 15 | }); 16 | -------------------------------------------------------------------------------- /testing/tests/historyTable_test.js: -------------------------------------------------------------------------------- 1 | const { MainPage, CommonPage } = inject(); 2 | 3 | Feature('mainPage > historyTable'); 4 | 5 | const quickItems = new DataTable(['item', 'image']); 6 | quickItems.add([{ child: 'b', parent: 's', canInclude: 'yes' }, 'b_s_yes_history_single_row.png']); 7 | quickItems.add([{ child: 'p', parent: 'ul', canInclude: 'no' }, 'p_ul_no_history_single_row.png']); 8 | quickItems.add([{ child: 'img', parent: 'a', canInclude: 'doubt' }, 'img_a_doubt_single_history_row.png']); 9 | 10 | Data(quickItems) 11 | .Scenario('10 quick results pair single item with different status yes|no|doubt', ({ I, current }) => { 12 | I.haveHistoryItemInDb(current.item); 13 | MainPage.amOnPage(); 14 | I.saveElementScreenshot(MainPage.tables.row(1), current.image); 15 | I.seeVisualDiffForElement(MainPage.tables.row(1), current.image, { 16 | tolerance: 0, 17 | prepareBaseImage: false, 18 | scaleToSameSize: true, 19 | ignore: 'antialiasing', 20 | }); 21 | }).tag('@db'); 22 | 23 | Scenario('Max rows in last quick results table', async ({ I }) => { 24 | const MaxCountOfRows = 10; 25 | const rows = await I.haveANumberOfHistoryItemsInDb(20); 26 | MainPage.amOnPage(); 27 | I.seeNumberOfVisibleElements(MainPage.tables.quickResultsRows, MaxCountOfRows); 28 | await I.checkTableRow(MainPage.tables.row(1), MainPage.helpers.getLastRow(rows, ['result'])); 29 | }).tag('@db'); 30 | 31 | Scenario('Can follow by "result" link', async ({ I }) => { 32 | const [row] = await I.haveANumberOfHistoryItemsInDb(1); 33 | MainPage.amOnPage(); 34 | I.click(MainPage.tables.resultLink(1)); 35 | I.seeInCurrentUrl(CommonPage.helpers.detailUrl(row)); 36 | }).tag('@db'); 37 | -------------------------------------------------------------------------------- /testing/tests/mainPageDecoration_test.js: -------------------------------------------------------------------------------- 1 | const { MainPage, DataTables } = inject(); 2 | 3 | Feature('mainPageDecoration'); 4 | 5 | const dataTable1 = DataTables.decoration.mainPageDecoration; 6 | dataTable1.add('Main page decoration is turned on'); 7 | dataTable1.add('Main page decoration is turned off'); 8 | 9 | Data(dataTable1) 10 | .Scenario('Turn on/off main page decoration', async ({ I, current }) => { 11 | const { shouldSeeDecoration, envName, envValue } = current; 12 | I.amSettingAnEnvVariable(envName, envValue); 13 | MainPage.amOnPage(); 14 | if (shouldSeeDecoration) { 15 | I.seeElement(MainPage.decoration.lightRope); 16 | } else { 17 | I.dontSeeElement(MainPage.decoration.lightRope); 18 | } 19 | }); 20 | -------------------------------------------------------------------------------- /testing/tests/mainPage_test.js: -------------------------------------------------------------------------------- 1 | const { MainPage, DataTables } = inject(); 2 | 3 | Feature('mainPage'); 4 | 5 | Scenario('First page visit', async ({ I }) => { 6 | I.amOnPage('/'); 7 | I.seeTextEquals('Can I Include*', MainPage.labels.head); 8 | await I.checkAllLabels(MainPage.labels.quickResults, ['Last 0 Quick Results']); 9 | I.seeNumberOfVisibleElements(MainPage.tables.quickResultsRows, 0); 10 | await I.checkTableColumnNames(MainPage.tables.quickResultsHead, ['Child', 'Parent', 'Can Include?', 'Count', 'Link to']); 11 | await I.checkRequestsCounterValues(MainPage.counters.requests, `Counter: 1 req | 1 uniq | ${new Date().toJSON().slice(0, 10)}`); 12 | }).tag('@db'); 13 | 14 | Scenario('Counter incrementation', async ({ I }) => { 15 | I.amOnPage('/'); 16 | await I.checkRequestsCounterValues(MainPage.counters.requests, `Counter: 1 req | 1 uniq | ${new Date().toJSON().slice(0, 10)}`); 17 | I.refreshPage(); 18 | await I.checkRequestsCounterValues(MainPage.counters.requests, `Counter: 2 req | 1 uniq | ${new Date().toJSON().slice(0, 10)}`); 19 | I.refreshPage(); 20 | await I.checkRequestsCounterValues(MainPage.counters.requests, `Counter: 3 req | 1 uniq | ${new Date().toJSON().slice(0, 10)}`); 21 | }).tag('@db'); 22 | 23 | const dataTable1 = DataTables.likes.likesTable; 24 | dataTable1.add('One like'); 25 | dataTable1.add('More than one like'); 26 | dataTable1.add('Equal count of likes and dislikes'); 27 | dataTable1.add('No likes'); 28 | 29 | Data(dataTable1) 30 | .Scenario('Most liked table', async ({ I, current }) => { 31 | I.haveLikeItemsInDb(current.actualRows); 32 | MainPage.amOnPage(); 33 | I.seeNumberOfVisibleElements(MainPage.tables.mostLikedRows, current.expectedCountRows); 34 | await I.checkTableRow(MainPage.tables.mostLikedRows, current.expectedRows); 35 | }).tag('@db').tag('@skip'); 36 | 37 | const dataTable2 = DataTables.likes.dislikesTable; 38 | dataTable2.add('One dislike'); 39 | dataTable2.add('More than one dislike'); 40 | dataTable2.add('Equal count of likes and dislikes'); 41 | dataTable2.add('No dislikes'); 42 | 43 | Data(dataTable2) 44 | .Scenario('Most disliked table', async ({ I, current }) => { 45 | I.haveLikeItemsInDb(current.actualRows); 46 | MainPage.amOnPage(); 47 | I.seeNumberOfVisibleElements(MainPage.tables.mostDislikedRows, current.expectedCountRows); 48 | await I.checkTableRow(MainPage.tables.mostDislikedRows, current.expectedRows); 49 | }).tag('@db').tag('@skip'); 50 | -------------------------------------------------------------------------------- /testing/tests/recommendations_test.js: -------------------------------------------------------------------------------- 1 | const { DetailPage, DataTables } = inject(); 2 | 3 | Feature('recommendations'); 4 | 5 | const dataTable1 = DataTables.history.recommendationsByParentTag; 6 | dataTable1.add('Recommends most viewed at the same created date'); 7 | dataTable1.add('Recommends most viewd tag but older'); 8 | 9 | Data(dataTable1) 10 | .Scenario('Recommendation for parent tag of requested tags pair by "created" and "count" fields', ({ I, current }) => { 11 | const { historyItems, recommendationText } = current; 12 | I.haveHistoryItemsInDb(historyItems); 13 | DetailPage.amOnPage('b', 'span'); 14 | DetailPage.seeRecommendationText(recommendationText); 15 | }).tag('@db'); 16 | -------------------------------------------------------------------------------- /utils.js: -------------------------------------------------------------------------------- 1 | const events = require('events'); 2 | const sqlite3 = require("sqlite3").verbose(); 3 | const md5 = require('md5'); 4 | const util = require('util'); 5 | 6 | const CronJob = require('cron').CronJob; 7 | 8 | class Scheduler { 9 | constructor(trackPeriodInMs = 2 * 1000 * 60) { 10 | this.trackPeriodInMs = trackPeriodInMs; 11 | this.emitter = new events.EventEmitter(); 12 | this.emitter.addListener('next', this.next); 13 | } 14 | 15 | schedule(handler) { 16 | this.emitter.addListener('perform', handler); 17 | } 18 | 19 | start() { 20 | this.next(); 21 | } 22 | 23 | next = () => { 24 | this.timerId = setTimeout( 25 | () => this.emitter.emit('perform'), 26 | this.trackPeriodInMs 27 | ); 28 | } 29 | } 30 | 31 | function shortenNumber(n, d) { 32 | if (n < 1) return "0"; 33 | var k = n = Math.floor(n); 34 | if (n < 1000) return (n.toString().split("."))[0]; 35 | if (d !== 0) d = d || 1; 36 | 37 | function shorten(a, b, c) { 38 | var d = a.toString().split("."); 39 | if (!d[1] || b === 0) { 40 | return d[0] + c 41 | } else { 42 | return d[0] + "." + d[1].substring(0, b) + c; 43 | } 44 | } 45 | 46 | k = n / 1e15; if (k >= 1) return shorten(k, d, "Q"); 47 | k = n / 1e12; if (k >= 1) return shorten(k, d, "T"); 48 | k = n / 1e9; if (k >= 1) return shorten(k, d, "B"); 49 | k = n / 1e6; if (k >= 1) return shorten(k, d, "M"); 50 | k = n / 1e3; if (k >= 1) return shorten(k, d, "K"); 51 | } 52 | 53 | class DbConnection { 54 | constructor(dbFile = "./.data/sqlite.db") { 55 | this.dbFile = dbFile; 56 | this.isOpen = false; 57 | this.initConnection(); 58 | this.resetCallbacks = []; 59 | this.closeCallbacks = []; 60 | } 61 | 62 | initConnection() { 63 | this.database = new sqlite3.Database(this.dbFile); 64 | this.database.on('open', () => { 65 | this.isOpen = true; 66 | }); 67 | this.database.on('close', () => { 68 | this.isOpen = false; 69 | }); 70 | this.asyncClose = util.promisify(this.database.close).bind(this.database); 71 | } 72 | 73 | setup() { 74 | this.database.serialize(() => { 75 | this.database.run('PRAGMA journal_mode = WAL;'); 76 | this.database.run('PRAGMA auto_vacuum = FULL;'); 77 | // this.database.run('PRAGMA recursive_triggers=1;'); 78 | this.database.run(` 79 | CREATE TABLE IF NOT EXISTS feedbacks ( 80 | id INTEGER PRIMARY KEY AUTOINCREMENT, 81 | user TEXT NOT NULL, 82 | text TEXT NOT NULL CHECK (length(text) >= 1 AND length(text) <= 280), 83 | key TEXT NOT NULL, 84 | parent TEXT NOT NULL, 85 | child TEXT NOT NULL, 86 | resolved INTEGER NO NULL DEFAULT 0 CHECK (resolved = 0 OR resolved = 1), 87 | approved INTEGER NO NULL DEFAULT 0 CHECK (resolved = 0 OR resolved = 1), 88 | created TEXT NOT NULL DEFAULT(datetime('now')), 89 | updatedAt TEXT NOT NULL DEFAULT(datetime('now')), 90 | UNIQUE(key) 91 | ); 92 | `); 93 | this.database.run(`CREATE INDEX IF NOT EXISTS idx_feedbacks_user_parent_child ON feedbacks(user, parent, child);`); 94 | this.database.run(`CREATE INDEX IF NOT EXISTS idx_feedbacks_resolved ON feedbacks(resolved);`); 95 | this.database.run(`CREATE INDEX IF NOT EXISTS idx_feedbacks_approved ON feedbacks(approved);`); 96 | this.database.run(`CREATE INDEX IF NOT EXISTS idx_feedbacks_created ON feedbacks(created);`); 97 | 98 | this.database.run(` 99 | CREATE TRIGGER IF NOT EXISTS [trg_feedbacks_updatedAt] 100 | AFTER UPDATE 101 | ON feedbacks 102 | BEGIN 103 | UPDATE feedbacks SET updatedAt=datetime('now') WHERE id=OLD.id; 104 | END; 105 | `); 106 | 107 | this.database.run(` 108 | CREATE TABLE IF NOT EXISTS likes ( 109 | id INTEGER PRIMARY KEY AUTOINCREMENT, 110 | user TEXT NOT NULL, 111 | parent TEXT NOT NULL, 112 | child TEXT NOT NULL, 113 | type TEXT NOT NULL DEFAULT('like') CHECK (type = 'like' OR type = 'dislike' OR type = 'unknown'), 114 | created TEXT NOT NULL DEFAULT(datetime('now')), 115 | updatedAt TEXT NOT NULL DEFAULT(datetime('now')), 116 | UNIQUE(user, parent, child) 117 | ); 118 | `); 119 | 120 | this.database.run(` 121 | CREATE TRIGGER IF NOT EXISTS [trg_likes_updatedAt] 122 | AFTER UPDATE 123 | ON likes 124 | BEGIN 125 | UPDATE likes SET updatedAt=datetime('now') WHERE id=OLD.id; 126 | END; 127 | `); 128 | 129 | this.database.run(`CREATE INDEX IF NOT EXISTS idx_likes_parent_child ON likes(parent, child);`); 130 | this.database.run(`CREATE INDEX IF NOT EXISTS idx_likes_created ON likes(created);`); 131 | 132 | this.database.run(` 133 | CREATE TABLE IF NOT EXISTS counters ( 134 | id INTEGER PRIMARY KEY AUTOINCREMENT, 135 | key TEXT NOT NULL, 136 | count INTEGER NOT NULL DEFAULT(0), 137 | created TEXT NOT NULL DEFAULT(date('now')), 138 | updatedAt TEXT NOT NULL DEFAULT(datetime('now')), 139 | UNIQUE(key, created) 140 | ); 141 | `); 142 | 143 | this.database.run(` 144 | CREATE TRIGGER IF NOT EXISTS [trg_counters_updatedAt] 145 | AFTER UPDATE 146 | ON counters 147 | BEGIN 148 | UPDATE counters SET updatedAt=datetime('now') WHERE id=OLD.id; 149 | END; 150 | `); 151 | 152 | this.database.run(` 153 | CREATE TABLE IF NOT EXISTS history ( 154 | id INTEGER PRIMARY KEY AUTOINCREMENT, 155 | child TEXT NOT NULL, 156 | parent TEXT NOT NULL, 157 | canInclude INTEGER NOT NULL DEFAULT('no') CHECK (canInclude = 'yes' OR canInclude = 'no' OR canInclude = 'doubt'), 158 | count INTEGER NOT NULL DEFAULT(0), 159 | created TEXT NOT NULL DEFAULT(date('now')), 160 | updatedAt TEXT NOT NULL DEFAULT(datetime('now')), 161 | UNIQUE(child, parent, canInclude, created) 162 | ); 163 | `); 164 | 165 | this.database.run(` 166 | CREATE TRIGGER IF NOT EXISTS [trg_history_updatedAt] 167 | AFTER UPDATE 168 | ON history 169 | BEGIN 170 | UPDATE history SET updatedAt=datetime('now') WHERE id=OLD.id; 171 | END; 172 | `); 173 | 174 | this.database.run(`CREATE INDEX IF NOT EXISTS idx_history_created ON history(created);`); 175 | this.database.run(`CREATE INDEX IF NOT EXISTS idx_history_count ON history(count);`); 176 | this.database.run(`CREATE INDEX IF NOT EXISTS idx_history_child_parent_created ON history(child, parent, created);`); 177 | this.database.run(`CREATE INDEX IF NOT EXISTS idx_history_updatedAt ON history(updatedAt);`); 178 | 179 | this.database.run(` 180 | CREATE TABLE IF NOT EXISTS invites ( 181 | id INTEGER PRIMARY KEY AUTOINCREMENT, 182 | key TEXT NOT NULL, 183 | user TEXT NULL, 184 | role TEXT NOT NULL, 185 | used INTEGER NOT NULL DEFAULT(0) CHECK (used = 0 OR used = 1), 186 | created TEXT NOT NULL DEFAULT(date('now')), 187 | updatedAt TEXT NOT NULL DEFAULT(datetime('now')), 188 | UNIQUE(key, role) 189 | ); 190 | `); 191 | 192 | this.database.run(`CREATE INDEX IF NOT EXISTS idx_invites_used ON invites(used);`); 193 | 194 | this.database.run(` 195 | CREATE TRIGGER IF NOT EXISTS [trg_invites_updatedAt] 196 | AFTER UPDATE 197 | ON invites 198 | BEGIN 199 | UPDATE invites SET updatedAt=datetime('now') WHERE id=OLD.id; 200 | END; 201 | `); 202 | }); 203 | } 204 | 205 | set onUpdate (cb) { 206 | this.resetCallbacks.push(cb); 207 | } 208 | 209 | set onClose(cb) { 210 | this.closeCallbacks.push(cb); 211 | } 212 | 213 | callResetCallbacks() { 214 | if (this.resetCallbacks.length) { 215 | this.resetCallbacks.map(cb => typeof cb === 'function' && cb()) 216 | } 217 | } 218 | 219 | callCloseCallbacks() { 220 | if (this.closeCallbacks.length) { 221 | this.closeCallbacks.map(cb => typeof cb === 'function' && cb()) 222 | } 223 | } 224 | 225 | async close() { 226 | if (this.isOpen) { 227 | await this.asyncClose(); 228 | } 229 | this.callCloseCallbacks(); 230 | } 231 | 232 | async reset() { 233 | if(this.isOpen) { 234 | await this.close(); 235 | this.database = new sqlite3.Database(this.dbFile); 236 | this.initConnection(); 237 | this.setup(); 238 | this.callResetCallbacks(); 239 | } 240 | } 241 | } 242 | 243 | class DbManager { 244 | constructor(conn) { 245 | this.conn = conn; 246 | this.conn.onUpdate = () => { 247 | this.bindMethods(); 248 | }; 249 | this.bindMethods(); 250 | } 251 | 252 | bindMethods() { 253 | this.getAsync = util.promisify(this.db.get).bind(this.db); 254 | this.allAsync = util.promisify(this.db.all).bind(this.db); 255 | this.runAsync = util.promisify(this.db.run).bind(this.db); 256 | } 257 | 258 | get db() { 259 | return this.conn.database; 260 | } 261 | 262 | runOneByOne(cb) { 263 | this.db.serialize(cb); 264 | } 265 | } 266 | 267 | class DailyFeedbackExceededError extends Error { 268 | constructor() { 269 | super('FEEDBACK limit exceeded'); 270 | } 271 | } 272 | 273 | class FeedbackManager extends DbManager { 274 | canAddFeedback(limit=5) { 275 | return new Promise((resolve, reject) => { 276 | if (limit === 0) reject(new DailyFeedbackExceededError()); 277 | this.db.get( 278 | `SELECT COUNT(id) as count FROM feedbacks WHERE date(created)=?`, 279 | [new Date().toISOString().substring(0, 10)], 280 | (err, row) => { 281 | if (err) { 282 | return reject(err); 283 | } 284 | if (row.count < limit) { 285 | resolve(true); 286 | } else { 287 | reject(new DailyFeedbackExceededError()); 288 | } 289 | }); 290 | }); 291 | } 292 | 293 | countByTags({ parent, child }) { 294 | return new Promise((resolve, reject) => { 295 | this.db.get( 296 | `SELECT COUNT(id) as count 297 | FROM feedbacks WHERE 298 | parent=? AND 299 | child=?;`, 300 | [parent, child], 301 | (err, row) => { 302 | if (err) { 303 | return reject(err); 304 | } 305 | resolve(shortenNumber(row.count)); 306 | } 307 | ); 308 | }); 309 | } 310 | 311 | countAll() { 312 | return new Promise((resolve, reject) => { 313 | this.db.get(`SELECT COUNT(id) as count FROM feedbacks;`, [], (err, row) => { 314 | if (err) { 315 | return reject(err); 316 | } 317 | return resolve(shortenNumber(row.count)); 318 | }); 319 | }); 320 | } 321 | 322 | add({ user, text, parent, child }) { 323 | return new Promise((resolve, reject) => { 324 | const pairKey = `${parent}:${child}`; 325 | const key = md5(`${user}:${text}:${pairKey}`); 326 | 327 | this.db.run(`INSERT INTO feedbacks (user, key, parent, child, text) VALUES (?,?,?,?,?);`, [user, key, parent, child, text], function (err) { 328 | if (err) { 329 | return reject(err); 330 | } 331 | resolve(this.changes); 332 | }); 333 | }); 334 | } 335 | 336 | getLastFeedbacks({ user, parent, child }) { 337 | return new Promise((resolve, reject) => { 338 | this.db.all(` 339 | SELECT * FROM feedbacks WHERE 340 | user=? AND 341 | parent=? AND 342 | child=? 343 | ORDER BY created DESC 344 | LIMIT 10`, 345 | [user, parent, child], 346 | function (err, rows) { 347 | if (err) { 348 | return reject(err); 349 | } 350 | resolve(rows); 351 | } 352 | ); 353 | }); 354 | } 355 | 356 | approve({ id }) { 357 | return this.runAsync(`UPDATE feedbacks SET approved=1 WHERE id=?`, [id]); 358 | } 359 | 360 | unapprove({ id }) { 361 | return this.runAsync(`UPDATE feedbacks SET approved=0 WHERE id=?`, [id]); 362 | } 363 | 364 | resolve({ id }) { 365 | return this.runAsync(`UPDATE feedbacks SET resolved=1 WHERE id=?`, [id]); 366 | } 367 | 368 | unresolve({ id }) { 369 | return this.runAsync(`UPDATE feedbacks SET resolved=0 WHERE id=?`, [id]); 370 | } 371 | 372 | remove({ id }) { 373 | return this.runAsync(`DELETE FROM feedbacks WHERE id=?`, [id]); 374 | } 375 | 376 | async getAllByPage({ page }) { 377 | const row = await this.getAsync('SELECT COUNT(id) as count FROM feedbacks;', []); 378 | const count = row.count || 0; 379 | const MaxPages = Math.floor(count / 10) + (count % 10 !== 0 ? 1 : 0); 380 | const offset = ((page - 1) * 10) % (count || 1); 381 | const rows = await this.allAsync(`SELECT * FROM feedbacks ORDER BY created DESC LIMIT 10 OFFSET ${offset};`, []); 382 | return { currentPage: page, feedbacks: rows, totalPages: MaxPages }; 383 | } 384 | } 385 | 386 | class RecordNotFoundError extends Error { 387 | constructor(message) { 388 | super(message || 'Record not found'); 389 | } 390 | } 391 | 392 | class LikesManager extends DbManager { 393 | getCount(parent, child, type='like') { 394 | return new Promise((resolve, reject) => { 395 | this.db.get(`SELECT COUNT(id) as count FROM likes WHERE parent=? AND child=? AND type=?`, [parent, child, type], function (err, row) { 396 | if (err) { 397 | return reject(err); 398 | } 399 | resolve(row && row.count || 0); 400 | }); 401 | }); 402 | } 403 | 404 | getLike(user, parent, child) { 405 | return new Promise((resolve, reject) => { 406 | this.db.get(`SELECT id, type FROM likes WHERE user=? AND parent=? AND child=?`, [user, parent, child], function (err, row) { 407 | if (err) { 408 | return reject(err); 409 | } 410 | if (!row) { 411 | return reject(new RecordNotFoundError()); 412 | } 413 | resolve(row); 414 | }); 415 | }); 416 | } 417 | 418 | async getLikeSafe(user, parent, child, defaultValue = {}) { 419 | try { 420 | return await this.getLike(user, parent, child); 421 | } catch(e) { 422 | if (typeof e === RecordNotFoundError) { 423 | return defaultValue; 424 | } 425 | } 426 | } 427 | 428 | createLike(user, parent, child, type='like') { 429 | return new Promise((resolve, reject) => { 430 | this.db.run(`INSERT INTO likes(user, parent, child, type) VALUES(?,?,?,?)`, [user, parent, child, type], function (err) { 431 | if (err) { 432 | return reject(err); 433 | } 434 | resolve(this.lastID); 435 | }); 436 | }); 437 | } 438 | 439 | updateLike(id, type = 'like') { 440 | return new Promise((resolve, reject) => { 441 | this.db.get(`UPDATE likes SET type=? WHERE id=?`, [type, id], function (err) { 442 | if (err) { 443 | return reject(err); 444 | } 445 | resolve(this.changes); 446 | }); 447 | }); 448 | } 449 | 450 | async like(user, parent, child, type='like') { 451 | try { 452 | const { id } = await this.getLike(user, parent, child); 453 | await this.updateLike(id, type); 454 | } catch (e) { 455 | await this.createLike(user, parent, child, type); 456 | } 457 | } 458 | 459 | async unlike(user, parent, child) { 460 | return this.like(user, parent, child, 'unknown'); 461 | } 462 | 463 | async dislike(user, parent, child) { 464 | return this.like(user, parent, child, 'dislike'); 465 | } 466 | 467 | async undislike(user, parent, child) { 468 | return this.like(user, parent, child, 'unknown'); 469 | } 470 | 471 | async votes(user, parent, child) { 472 | const likes = await this.getCount(parent, child, 'like'); 473 | const dislikes = await this.getCount(parent, child, 'dislike'); 474 | const like = await this.getLikeSafe(user, parent, child, { type: 'unknown' }); 475 | return { 476 | likes: shortenNumber(likes), 477 | dislikes: shortenNumber(dislikes), 478 | disliked: like && like.type === 'dislike' || false, 479 | liked: like && like.type === 'like' || false, 480 | user 481 | }; 482 | } 483 | } 484 | 485 | class Counter extends DbManager { 486 | constructor(dbConn) { 487 | super(dbConn); 488 | this.totalCount = 0; 489 | this.uniqTotalCount = 0; 490 | } 491 | 492 | get count() { 493 | return this.totalCount; 494 | } 495 | 496 | get uniqCount() { 497 | return this.uniqTotalCount; 498 | } 499 | 500 | async getBy({ key, date }) { 501 | return new Promise((resolve, reject) => { 502 | this.db.get( 503 | 'SELECT key, count FROM counters WHERE key=? AND created=?', 504 | [key, date.toISOString().slice(0,10)], 505 | function (err, row) { 506 | if (err) { 507 | return reject(err); 508 | } 509 | if (!row) { 510 | return reject(new RecordNotFoundError()); 511 | } 512 | resolve(row); 513 | }); 514 | }); 515 | } 516 | 517 | async create({ key }) { 518 | return new Promise((resolve, reject) => { 519 | this.db.run('INSERT INTO counters(key, count) VALUES(?,?)', [key, 1], function (err) { 520 | if (err) { 521 | return reject(err); 522 | } 523 | resolve(this.lastID); 524 | }); 525 | }); 526 | } 527 | 528 | async update({ key }) { 529 | return new Promise((resolve, reject) => { 530 | this.db.run( 531 | 'UPDATE counters SET count=count+1 WHERE key=? AND created=?', 532 | [ 533 | key, 534 | new Date().toISOString().slice(0, 10) 535 | ], 536 | function (err) { 537 | if (err) { 538 | return reject(err); 539 | } 540 | resolve(this.lastID); 541 | }); 542 | }); 543 | } 544 | 545 | async getTotals() { 546 | return new Promise((resolve, reject) => { 547 | this.db.get( 548 | 'SELECT COUNT(id) as uniqCount, SUM(count) as totalCount FROM counters WHERE created=? GROUP BY date(created)', 549 | [new Date().toISOString().slice(0, 10)], 550 | function (err, row) { 551 | if (err) { 552 | return reject(err); 553 | } 554 | if (!row) { 555 | resolve({ uniqCount: 0, totalCount: 0 }); 556 | } 557 | resolve(row); 558 | }); 559 | }); 560 | } 561 | 562 | async register(ip) { 563 | const key = md5(ip); 564 | try { 565 | const record = await this.getBy({ key, date: new Date() }); 566 | await this.update({ key: record.key }); 567 | } catch (e) { 568 | if (e instanceof RecordNotFoundError) { 569 | await this.create({ key }); 570 | } 571 | } 572 | } 573 | 574 | async load() { 575 | const result = await this.getTotals(); 576 | this.totalCount = result && result.totalCount || 0; 577 | this.uniqTotalCount = result && result.uniqCount || 0; 578 | } 579 | } 580 | 581 | class HistoryManager extends DbManager { 582 | async getBy({ parent, child, date }) { 583 | return new Promise((resolve, reject) => { 584 | this.db.get( 585 | 'SELECT id, parent, child, canInclude FROM history WHERE parent=? AND child=? AND created=?', 586 | [parent, child, date.toISOString().slice(0, 10)], 587 | function (err, row) { 588 | if (err) { 589 | return reject(err); 590 | } 591 | if (!row) { 592 | return reject(new RecordNotFoundError()); 593 | } 594 | resolve(row); 595 | }); 596 | }); 597 | } 598 | 599 | async getLastBy() { 600 | return new Promise((resolve, reject) => { 601 | this.db.all( 602 | `SELECT id, parent, child, canInclude, count FROM history ORDER BY updatedAt DESC LIMIT 10`, 603 | [], 604 | function (err, rows) { 605 | if (err) { 606 | return reject(err); 607 | } 608 | resolve(rows); 609 | }); 610 | }); 611 | } 612 | 613 | async create({ parent, child, canInclude}) { 614 | return new Promise((resolve, reject) => { 615 | this.db.run( 616 | 'INSERT INTO history(parent, child, canInclude, count) VALUES(?,?,?,1)', 617 | [parent, child, canInclude.toLowerCase()], 618 | function (err) { 619 | if (err) { 620 | return reject(err); 621 | } 622 | resolve(this.lastID); 623 | }); 624 | }); 625 | } 626 | 627 | async updateCountBy({ parent, child, date }) { 628 | return new Promise((resolve, reject) => { 629 | this.db.run( 630 | 'UPDATE history SET count=count+1 WHERE parent=? AND child=? AND created=?', 631 | [ 632 | parent, 633 | child, 634 | date.toISOString().slice(0, 10) 635 | ], 636 | function (err) { 637 | if (err) { 638 | return reject(err); 639 | } 640 | resolve(this.lastID); 641 | }); 642 | }); 643 | } 644 | 645 | async register({ parent, child, canInclude }) { 646 | try { 647 | const date = new Date(); 648 | const record = await this.getBy({ parent, child, date }); 649 | await this.updateCountBy({ parent: record.parent, child: record.child, date }); 650 | } catch (e) { 651 | if (e instanceof RecordNotFoundError) { 652 | await this.create({ parent, child, canInclude }); 653 | } 654 | } 655 | } 656 | } 657 | 658 | class InvitesManager extends DbManager { 659 | async apply({ key, user }) { 660 | const result = await this.getAsync(`SELECT id FROM invites WHERE key=? AND used=0`, [key]); 661 | if (!result) { 662 | throw new RecordNotFoundError(); 663 | } 664 | await this.runAsync(`UPDATE invites SET user=?, used=1 WHERE key=?`, [user, key]); 665 | return this.getAsync(`SELECT * FROM invites WHERE key=? AND used=1`, [key]); 666 | } 667 | } 668 | 669 | class StatManager extends DbManager { 670 | constructor(conn) { 671 | super(conn); 672 | this.cacheKey = null; 673 | this.maxUniqCount = 0; 674 | this.cacheValues = null; 675 | } 676 | get totalCount() { 677 | return this.maxUniqCount; 678 | } 679 | async getStatCountersFor2Weeks() { 680 | const shortDateNow = new Date().toISOString().substring(0, 10); 681 | if (this.cacheKey !== shortDateNow) { 682 | this.cacheKey = shortDateNow; 683 | const { maximum } = await this.getAsync('SELECT MAX(count) as maximum from (SELECT COUNT(id) as count FROM counters c2 GROUP BY c2.created)'); 684 | this.maxUniqCount = maximum; 685 | this.cacheValues = await this.allAsync(`SELECT curr.count as nowCount, prev.count as prevCount, curr.dayofweek FROM 686 | (SELECT COUNT(id) as count, c2.created, 687 | case cast (strftime('%w', c2.created) as integer) 688 | when 0 then 'Su' 689 | when 1 then 'Mo' 690 | when 2 then 'Tu' 691 | when 3 then 'We' 692 | when 4 then 'Th' 693 | when 5 then 'Fr' 694 | else 'Sa' 695 | end as dayofweek 696 | FROM counters c2 697 | WHERE c2.created >= date('${shortDateNow}', '-13 days') 698 | AND c2.created <= date('${shortDateNow}', '-7 days') 699 | GROUP BY c2.created ORDER BY c2.created) as prev 700 | LEFT JOIN 701 | (SELECT COUNT(id) as count, c2.created, 702 | case cast (strftime('%w', c2.created) as integer) 703 | when 0 then 'Su' 704 | when 1 then 'Mo' 705 | when 2 then 'Tu' 706 | when 3 then 'We' 707 | when 4 then 'Th' 708 | when 5 then 'Fr' 709 | else 'Sa' 710 | end as dayofweek 711 | FROM counters c2 712 | WHERE c2.created >= date('${shortDateNow}', '-6 days') 713 | AND c2.created <= '${shortDateNow}' 714 | GROUP BY c2.created ORDER BY c2.created) as curr 715 | ON curr.dayofweek = prev.dayofweek` 716 | ); 717 | } 718 | return Promise.resolve(this.cacheValues); 719 | } 720 | } 721 | 722 | function getBarCssByValues(left, right, total) { 723 | const leftValue = Number(left); 724 | const rightValue = Number(right); 725 | const isLower = leftValue < rightValue; 726 | const heightInPercent = Number(((leftValue * 100) / total).toFixed(2)); 727 | const zIndex = isLower ? 2 : 1; 728 | return { 729 | zIndex, 730 | heightInPercent 731 | } 732 | } 733 | 734 | class CronDbManager extends DbManager { 735 | startCronJob({ cronTime, onTick, ...otherConfigProps }) { 736 | if (!this.cron) { 737 | this.cron = new CronJob({ cronTime, onTick, runOnInit: true, ...otherConfigProps }); 738 | this.cron.start(); 739 | } 740 | } 741 | stopCronJob() { 742 | if (this.cron) { 743 | this.cron.stop(); 744 | this.cron = null; 745 | } 746 | } 747 | } 748 | 749 | class BaseCronDbManager extends CronDbManager { 750 | constructor(conn) { 751 | super(conn); 752 | this.conn.onUpdate = () => this.restart(); 753 | this.conn.onClose = () => this.stop(); 754 | this.cache = {}; 755 | } 756 | 757 | setCronTime(value) { 758 | this.cronTime = value; 759 | } 760 | 761 | start() { 762 | this.startCronJob({ 763 | cronTime: this.cronTime, 764 | onTick: () => { 765 | this.cache = {}; 766 | } 767 | }); 768 | } 769 | 770 | stop() { 771 | this.stopCronJob(); 772 | } 773 | 774 | restart() { 775 | this.stop(); 776 | this.start(); 777 | } 778 | 779 | hasKey(cacheKey) { 780 | return typeof this.cache[cacheKey] !== 'undefined'; 781 | } 782 | 783 | getByKey(cacheKey) { 784 | return this.cache[cacheKey]; 785 | } 786 | 787 | setByKey(cacheKey, value) { 788 | this.cache[cacheKey] = value; 789 | } 790 | } 791 | 792 | class SimpleRecommendManager extends BaseCronDbManager { 793 | constructor(conn) { 794 | super(conn); 795 | this.setCronTime(process.env.RECOMMEND_CLEAR_CACHE_CRON_TIME || '0 */30 * * * *'); 796 | } 797 | 798 | async getFromCacheOrQuery(childTagName, parentTagName) { 799 | const cacheKey = `${childTagName}${parentTagName}`; 800 | if (this.hasKey(cacheKey)) { 801 | return this.getByKey(); 802 | } 803 | 804 | const record = await this.getAsync(` 805 | select child, parent, count, (julianday('now') - julianday(created)) / 365 as attenuation_factor 806 | from history 807 | where child=? order by attenuation_factor ASC, count DESC LIMIT 1`, 808 | [parentTagName]); 809 | 810 | this.setByKey(cacheKey, record); 811 | return record; 812 | } 813 | } 814 | 815 | class StatLikesManager extends BaseCronDbManager { 816 | constructor(conn) { 817 | super(conn); 818 | this.setCronTime(process.env.RECOMMEND_CLEAR_CACHE_CRON_TIME || '0 */30 * * * *'); 819 | } 820 | 821 | async getMostLiked() { 822 | const cacheKey = 'CACHED_LIKED_RESULT'; 823 | if (this.hasKey(cacheKey)) { 824 | return this.getByKey(cacheKey); 825 | } 826 | 827 | const records = await this.allAsync(` 828 | select * from (select 829 | parent, 830 | child, 831 | SUM(case type 832 | when 'like' then 1 833 | else 0 834 | end) display, 835 | SUM(case type 836 | when 'dislike' then 1 837 | else 0 838 | end) disliked, 839 | count(id) as count from likes 840 | where type in ('like', 'dislike') 841 | group by parent, child 842 | order by count desc 843 | limit 10) where display > disliked;`); 844 | 845 | if (records && records.length) { 846 | this.setByKey(cacheKey, records); 847 | } 848 | return records; 849 | } 850 | 851 | async getMostDisliked() { 852 | const cacheKey = 'CACHED_DISLIKED_RESULT'; 853 | if (this.hasKey(cacheKey)) { 854 | return this.getByKey(cacheKey); 855 | } 856 | 857 | const records = await this.allAsync(`select * from (select 858 | parent, 859 | child, 860 | SUM(case type 861 | when 'like' then 1 862 | else 0 863 | end) liked, 864 | SUM(case type 865 | when 'dislike' then 1 866 | else 0 867 | end) display, 868 | count(id) as count from likes 869 | where type in ('like', 'dislike') 870 | group by parent, child 871 | order by count desc 872 | limit 10) where liked < display;`); 873 | 874 | if (records && records.length) { 875 | this.setByKey(cacheKey, records); 876 | } 877 | return records; 878 | } 879 | } 880 | 881 | module.exports.StatManager = StatManager; 882 | module.exports.Scheduler = Scheduler; 883 | module.exports.Counter = Counter; 884 | module.exports.shortenNumber = shortenNumber; 885 | module.exports.DbConnection = DbConnection; 886 | module.exports.FeedbackManager = FeedbackManager; 887 | module.exports.LikesManager = LikesManager; 888 | module.exports.HistoryManager = HistoryManager; 889 | module.exports.InvitesManager = InvitesManager; 890 | module.exports.RecordNotFoundError = RecordNotFoundError; 891 | module.exports.getBarCssByValues = getBarCssByValues; 892 | module.exports.SimpleRecommendManager = SimpleRecommendManager; 893 | module.exports.StatLikesManager = StatLikesManager; 894 | --------------------------------------------------------------------------------