├── .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 |
9 |
10 | ${new Array(totalPages).fill(0).map((_, index) => {
11 | const pageNumber = index + 1;
12 | return pageNumber === currentPage ? html`
${pageNumber} ` : html`
${pageNumber} `
13 | })}
14 |
15 |
16 |
17 |
18 | ID
19 | Text
20 | UserId
21 | Tags Pair
22 | Resolved
23 | Approved
24 |
25 |
26 |
27 |
28 |
29 |
30 | ${ !feedbacks.length && html`Empty ` }
31 | ${ feedbacks.map(feedback => html`
32 |
33 | ${feedback.id}
34 | ${feedback.text}
35 | ${feedback.user}
36 | ${feedback.child} - ${feedback.parent}
37 | ${feedback.resolved ? 'YES' : 'NO'}
38 | ${feedback.approved ? 'YES' : 'NO'}
39 | ${feedback.approved ? 'Unapprove' : 'Approve' }
40 | ${feedback.resolved ? 'Unresolve' : 'Resolve' }
41 | Remove
42 | `)
43 | }
44 |
45 |
46 |
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 | ${Main}>`;
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 | ${tags.length && tags[0].tags.list.map((item) => `<${item}/>`).join(', ')}
55 |
56 | ${tags.length && tags[2].tags.list.map((item) => `<${item}/>`).join(', ')}
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 | ${Tags}>
61 | <${About} show="${!hasTags}">
62 | <${MainSearchForm} ...${form} show="${!hasTags}" specVersion="${specVersion}" logoUrl="${logoUrl}" logoAlt="${logoAlt}"/>
63 |
64 | <${QuickResults} tagStats="${tagStats}"/>
65 |
66 |
67 | * This is an alpha version and uses a simple algorithm to test whether one tag can be included in another.
68 | ${About}>
69 |
70 | ${Main}>`;
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`${ch} `);
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 |
7 | This site uses cookies to evaluate the quality of the result of determining whether a tag can be included in a tag.
By accepting the use of cookies, you can vote “like” or “dislike”.
8 |
9 |
10 | Accept
11 |
12 |
13 |
`;
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!${ColorText}>
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 | ${Main}>`;
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 | `;
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 |
8 |
9 |
10 |
11 |
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*${ColorText}> `;
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 |
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 |
12 | Check when a tag can be included in another tag
13 |
23 | `;
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 |
13 |
14 |
15 | Child
16 | Parent
17 | Can Include?
18 | Count
19 | Link to
20 |
21 | ${tagStats.map(({
22 | child, parent, canInclude, count,
23 | }) => html`
24 |
25 | ${child}
26 | ${parent}
27 | ${canInclude}
28 | ${count}
29 | result
30 | `)}
31 |
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 | `) || 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 |
`
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 |
8 | Check when a tag can be included in another tag
9 |
10 |
11 |
18 |
19 |
20 | `;
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}">${TagItems}>
14 |
15 |
16 |
Contexts in which this element can be used
17 | <${TagItems} items="${tag.props.ContextsInWhichThisElementCanBeUsed}">${TagItems}>
18 |
19 |
20 |
Content model
21 | <${TagItems} items="${tag.props.ContentModel}">${TagItems}>
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 |
11 |
12 |
13 | Child
14 | Parent
15 | Count
16 | Link to
17 |
18 | ${items.map(({ child, parent, display }) => html`
19 |
20 | ${child}
21 | ${parent}
22 | ${display}
23 | result
24 | `)}
25 |
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 | Browser
9 | Web HTML
10 | Web API
11 | CanIUse
12 |
13 | ${Object.entries(data).map(([browser, row]) =>
14 | html`${browser} ${Object.values(row)
15 | .map(cell => html`${cell} `)} `)}
16 |
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 |
7 |
8 |
9 | Swap tag names
10 | `;
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 |
15 | Last 10 feedbacks by current tag pair
16 | Hide feedback
17 |
18 |
19 |
20 | Text
21 | Yours
22 | Resolved
23 | Approved
24 |
25 | ${ feedbacks.map(feedback => html`
26 |
27 | ${this.getText(feedback)}
28 |
29 |
30 |
31 | `)}
32 |
33 |
34 | ` || 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}">${TagItem}>`)}
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 |
--------------------------------------------------------------------------------