Merge branch 'zyachel:main' into mark-unanswered

This commit is contained in:
The Cashew Trader 2023-01-30 22:33:37 +05:30 committed by GitHub
commit 6dfe488602
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
72 changed files with 1481 additions and 596 deletions

View file

@ -1,11 +1,14 @@
NODE_ENV=production #if set to development, morgan middleware will log every request ## if set to development, morgan middleware will log every request
PORT=3000 # if unset, defaults back to 3000 NODE_ENV=production
CACHE_PERIOD=1h # duration for which static files' cached copies are valid in the browser(eg: 1m, 3600, '2 days'). defaults to 1h
#optional properties. default shown. enable by removing # in front of them. ### optional properties. default shown. enable by removing # in front of them.
# PORT=3000
## duration for which static files' cached copies are valid in the browser(eg: 1m, 3600, '2 days')
# CACHE_PERIOD=1h
## user agent and accept header that quora will see
# AXIOS_USER_AGENT='axios/0.26.1'
# AXIOS_ACCEPT='application/json, text/plain, */*'
#user agent and accept header that quora will see ### for specific use-cases.
#AXIOS_USER_AGENT='axios/0.26.1' ## add any value here (e.g.: 1, true, 'por favor') if you're using any service where http is the preferred method(e.g.: tor, i2p). else leave it blank
#AXIOS_ACCEPT='application/json, text/plain, */*'
# add any value here (e.g.: 1, true, 'por favor') if you're using any service where http is the preferred method, else leave it blank
NO_UPGRADE= NO_UPGRADE=

View file

@ -1,58 +1,56 @@
# [4.0.0](https://github.com/zyachel/quetre/compare/v3.3.1...v4.0.0) (2022-09-22) # [5.5.0](https://github.com/zyachel/quetre/compare/v5.4.0...v5.5.0) (2023-01-15)
### Bug Fixes ### Bug Fixes
* fix fetcher.js ([bf266a9](https://github.com/zyachel/quetre/commit/bf266a9a8971b55400f934a1e2338e83d8fd4d38)) * fix outgoing url on error page ([595b720](https://github.com/zyachel/quetre/commit/595b720ee12b234a9454e470139a3a40b4ad600f))
* ui fixes for webkit-based browsers ([44229f8](https://github.com/zyachel/quetre/commit/44229f87027b1b15d38c2739b9d85bec40a36bd8))
### BREAKING CHANGES
* previous fetcher.js won't work as Quora again changed their HTML
closes https://github.com/zyachel/quetre/issues/68
## [3.3.1](https://github.com/zyachel/quetre/compare/v3.3.0...v3.3.1) (2022-09-12)
### Bug Fixes
* broken layout on Tor instances ([dfec2b5](https://github.com/zyachel/quetre/commit/dfec2b5ebd0413606f64cd9f67a370aaf3d809fa))
# [3.3.0](https://github.com/zyachel/quetre/compare/v3.2.0...v3.3.0) (2022-08-03)
### Features ### Features
* add profile route ([49f5a3e](https://github.com/zyachel/quetre/commit/49f5a3e74e1c5cfd058ab2a1cc12bf5d9799a1c7)) * add redirection route ([4199bb3](https://github.com/zyachel/quetre/commit/4199bb38c379fa5e6c2c5e58098c63534c1743b5))
# [3.2.0](https://github.com/zyachel/quetre/compare/v3.1.1...v3.2.0) (2022-07-24) # [5.4.0](https://github.com/zyachel/quetre/compare/v5.3.0...v5.4.0) (2023-01-07)
### Bug Fixes ### Bug Fixes
* fix a fatal bug in viewController.js ([33c90c1](https://github.com/zyachel/quetre/commit/33c90c17b12cf15eadde16d35fbba4cede10919b)) * **routes:** add unimplemented error message to `space` route ([8820f36](https://github.com/zyachel/quetre/commit/8820f36af80f29d861a47526538293357e7c32f3))
* fix paragraph tag occuring inside heading tags ([65d14ba](https://github.com/zyachel/quetre/commit/65d14ba47c0d3bb1d2548972478a12a43f7e7500))
### Features ### Features
* add support for embedded content in answers ([bae2d7b](https://github.com/zyachel/quetre/commit/bae2d7b4f7f945d7eb55dddb4bd7e49ac21b2ae1)) * **lang:** add ability to choose language in search route ([cca6f69](https://github.com/zyachel/quetre/commit/cca6f69deda235fa87416e28a4dd557698974e3d))
## [3.1.1](https://github.com/zyachel/quetre/compare/v3.1.0...v3.1.1) (2022-07-22) # [5.3.0](https://github.com/zyachel/quetre/compare/v5.2.0...v5.3.0) (2023-01-07)
### Features
* add support for other languages ([d16ae48](https://github.com/zyachel/quetre/commit/d16ae48dcb762af6d0888b6fc556a04a4c954549))
# [5.2.0](https://github.com/zyachel/quetre/compare/v5.1.1...v5.2.0) (2022-11-27)
### Features
* self hosted mathjax ([30d06dc](https://github.com/zyachel/quetre/commit/30d06dc0ffa1b0b362952a16ebdccc9ec2b804b9))
## [5.1.1](https://github.com/zyachel/quetre/compare/v5.1.0...v5.1.1) (2022-10-30)
### Bug Fixes ### Bug Fixes
* browser theme preference not being respected when js is enabled ([40668b9](https://github.com/zyachel/quetre/commit/40668b92b5aa5c1b10cb265dc781066320cccce8)) * fix accidental console.log statement in template ([f719b3c](https://github.com/zyachel/quetre/commit/f719b3c4c91c504db35d1077bd05aa149b0f42db))

27
CONTRIBUTING.md Normal file
View file

@ -0,0 +1,27 @@
# Contributing to Quetre
First of all, thanks for reading this document!
## Bugs and issues
If you happen to come across and issue or bug in Quetre, You can create a new issue by clicking [here](https://github.com/zyachel/quetre/issues/new/choose). (You can also go to the GitHub issues tab).
**Issues can be feature requests too!**
Here are some nice tips for helpful issues:
- **Use a good title**
Issue titles are important since we use them to organize the issue board, therefore, please choose titles that are short and describe the issue in detail.
- **Include every detail you can think of**
This means logs, tracebacks, use cases (if the issue is about a feature)...
## Pull requests and commits
If you wish to contribute code, you can do so by forking the repository, doing the changes and then opening a pull request for your changes to be reviewed.
We just please ask you to write commit messages using the [Conventional Commits standard](https://www.conventionalcommits.org/en), there is a tool called [Commitizen](https://commitizen.github.io/cz-cli/) *(or CZ)* that automatically formats and names your commit using this standard, in case you are interested.
About the license, by contributing to Quetre, **you agree for all your contributions to be placed under the AGPL-v3.0 license.** You can view a copy [here](https://www.gnu.org/licenses/agpl-3.0.html).

206
README.md
View file

@ -1,6 +1,22 @@
<div align="center">
# Quetre # Quetre
[![Awesome Humane Tech](https://raw.githubusercontent.com/humanetech-community/awesome-humane-tech/main/humane-tech-badge.svg?sanitize=true)](https://github.com/humanetech-community/awesome-humane-tech) </div>
<div align="center">
[![Awesome Humane Tech](https://raw.githubusercontent.com/humanetech-community/awesome-humane-tech/main/humane-tech-badge.svg?sanitize=true)](https://github.com/humanetech-community/awesome-humane-tech)
[![GitHub issues](https://img.shields.io/github/issues/zyachel/quetre?style=flat-square)](https://github.com/zyachel/quetre/issues)
![GitHub pull requests](https://img.shields.io/github/issues-pr/zyachel/quetre?style=flat-square)
[![GitHub forks](https://img.shields.io/github/forks/zyachel/quetre?style=flat-square)](https://github.com/zyachel/quetre/network)
[![GitHub stars](https://img.shields.io/github/stars/zyachel/quetre?style=flat-square)](https://github.com/zyachel/quetre/stargazers)
[![GitHub license](https://img.shields.io/github/license/zyachel/quetre?style=flat-square)](https://github.com/zyachel/quetre/blob/main/LICENSE)
![GitHub contributors](https://img.shields.io/github/contributors/zyachel/quetre?style=flat-square)
![GitHub commit activity](https://img.shields.io/github/commit-activity/m/zyachel/quetre?style=flat-square)
![GitHub release (latest by date)](https://img.shields.io/github/v/release/zyachel/quetre?style=flat-square)
</div>
Quetre is an alternative front-end to Quora. Quetre is an alternative front-end to Quora.
It enables you to see answers without ads, trackers, and other such bloat. It enables you to see answers without ads, trackers, and other such bloat.
@ -9,29 +25,30 @@ It enables you to see answers without ads, trackers, and other such bloat.
## Key Features ## Key Features
- Privacy focused - **Privacy focused**
All requests are proxied which makes it impossible for Quora to collate meaningful data points about you. All requests are proxied which makes it impossible for Quora to collate meaningful data points about you.
- No ads or tracking - **No ads or tracking**
Absolutely no ads, no tracking, no browser fingerprinting, and no telemetry of any kind. Absolutely no ads, no tracking, no browser fingerprinting, and no telemetry of any kind.
- Fully responsive layout - **Fully responsive layout**
Utilises modern CSS features like CSS Grid and Flexbox to make the website fully responsive for all screen sizes. Utilises modern CSS features like CSS Grid and Flexbox to make the website fully responsive for all screen sizes.
- Lightweight and fast - **Lightweight and fast**
As the website contains no bloat, pages load in a jiffy and request sizes are tiny. As the website contains no bloat, pages load in a jiffy and request sizes are tiny.
- Dark and light themes - **Dark and light themes**
Whether you're a nightowl or bright screen lover, you'll enjoy curated color scheme for your taste. Whether you're a nightowl or bright screen lover, you'll enjoy curated color scheme for your taste.
- Unofficial API support - **Unofficial API support**
just add `/api/v1/` after the domain name in the URL and get a JSON response.
just add `/api/v1/` after the domain name in the URL and get a JSON response.
--- ---
@ -48,29 +65,38 @@ It enables you to see answers without ads, trackers, and other such bloat.
<!-- prettier-ignore --> <!-- prettier-ignore -->
| Instance | Region | Provider | Notes | | Instance | Region | Provider | Notes |
| -------- | ------ | -------- | ----- | | -------- | ------ | -------- | ----- |
| 1. Clearnet | | | | | **1. Clearnet** | | | |
| [quetre.iket.me](https://quetre.iket.me/) | Canada | OVHCloud | Official instance | | [quetre.iket.me](https://quetre.iket.me) | Canada | OVHCloud | Official instance |
| [quora.vern.cc](https://qr.vern.cc) | Canada | OVHCloud | Operated by [~vern](https://vern.cc/) | | [quora.vern.cc](https://qr.vern.cc) | US | Hetzner | Operated by [~vern](https://vern.cc/) |
| [quetre.pussthecat.org](https://quetre.pussthecat.org/) | Germany | &ndash; | Operated by [PussTheCat.org](https://pussthecat.org/) | | [quetre.pussthecat.org](https://quetre.pussthecat.org) | Germany | &ndash; | Operated by [PussTheCat.org](https://pussthecat.org/) |
| [wuetre.herokuapp.com](https://wuetre.herokuapp.com/) | Europe | Heroku | Operated by AnonymousZ |
| [quetreus.herokuapp.com](https://quetreus.herokuapp.com/) | U.S. | Heroku | Operated by [toyboatcash](https://github.com/toyboatcash) |
| [quetre.tokhmi.xyz](https://quetre.tokhmi.xyz/) | U.S. | Oracle | Operated by [Tokhmi](https://tokhmi.xyz) | | [quetre.tokhmi.xyz](https://quetre.tokhmi.xyz/) | U.S. | Oracle | Operated by [Tokhmi](https://tokhmi.xyz) |
| [quetre.projectsegfau.lt](https://quetre.projectsegfau.lt/) | Europe | BuyVM | Operated by [Project Segfault](https://projectsegfau.lt) | | [quetre.projectsegfau.lt](https://quetre.projectsegfau.lt) | Europe | BuyVM | Operated by [Project Segfault](https://projectsegfau.lt) |
| [quetre.esmailelbob.xyz](https://quetre.esmailelbob.xyz/) | Canada | OVHCloud | Operated by [Esmail EL BoB](https://esmailelbob.xyz/) | | [quetre.esmailelbob.xyz](https://quetre.esmailelbob.xyz) | Canada | OVHCloud | Operated by [Esmail EL BoB](https://esmailelbob.xyz/) |
| [quetre.odyssey346.dev](https://quetre.odyssey346.dev) | Poland | OVHCloud | Operated by [Odyssey346](https://odyssey346.dev/) | | [quetre.odyssey346.dev](https://quetre.odyssey346.dev) | Poland | OVHCloud | Operated by [Odyssey346](https://odyssey346.dev/) |
| 2. Onion | | | | | [quetre.privacydev.net](https://quetre.privacydev.net) | U.S. | BuyVM | Operated by [PrivacyDev](https://privacydev.net/) |
| [ask.habedieeh.re](https://ask.habedieeh.re) | Canada | Oracle | Operated by [habedieeh.re](https://www.habedieeh.re) |
| [quetre.marcopisco.com](https://quetre.marcopisco.com) | Portugal | Vodafone Portugal (Cloudflare) | Operated by [marcopisco.com](https://www.marcopisco.com) |
| [quetre.blackdrgn.nl](https://quetre.blackdrgn.nl) | Germany | Contabo | Operated by [blackdrgn.nl](https://blackdrgn.nl) |
| [quetre.pufe.org](https://quetre.pufe.org) | New Zealand | - | Operated by pufe.org |
| [quetre.lunar.icu](https://quetre.lunar.icu) | Germany | Cloudflare | Operated by [lunar.icu](https://lunar.icu/) |
| [que.wilbvr.me](https://que.wilbvr.me) | Netherlands | Liga Hosting | Operated by [Wilbvr](https://wilbvr.me) |
| [quora.femboy.hu](https://quora.femboy.hu) | Hungary | N/A (Self-hosted) | Operated by [hnhx](https://femboy.hu) |
| **2. Onion** | | | |
| [quetre.esmail5pdn24shtvieloeedh7ehz3nrwcdivnfhfcedl7gf4kwddhkqd.onion](http://quetre.esmail5pdn24shtvieloeedh7ehz3nrwcdivnfhfcedl7gf4kwddhkqd.onion) | Canada | OVHCloud | Operated by [Esmail EL BoB](https://esmailelbob.xyz/) | | [quetre.esmail5pdn24shtvieloeedh7ehz3nrwcdivnfhfcedl7gf4kwddhkqd.onion](http://quetre.esmail5pdn24shtvieloeedh7ehz3nrwcdivnfhfcedl7gf4kwddhkqd.onion) | Canada | OVHCloud | Operated by [Esmail EL BoB](https://esmailelbob.xyz/) |
| [qr.vernccvbvyi5qhfzyqengccj7lkove6bjot2xhh5kajhwvidqafczrad.onion](http://qr.vernccvbvyi5qhfzyqengccj7lkove6bjot2xhh5kajhwvidqafczrad.onion/) | Canada | OVHCloud | Operated by [~vern](https://vern.cc) | | [qr.vernccvbvyi5qhfzyqengccj7lkove6bjot2xhh5kajhwvidqafczrad.onion](http://qr.vernccvbvyi5qhfzyqengccj7lkove6bjot2xhh5kajhwvidqafczrad.onion/) | US | Hetzner | Operated by [~vern](https://vern.cc) |
| 3. I2P | | | | | [ask.habeehrhadazsw3izbrbilqajalfyqqln54mrja3iwpqxgcuxnus7eid.onion](http://ask.habeehrhadazsw3izbrbilqajalfyqqln54mrja3iwpqxgcuxnus7eid.onion/) | Canada | Oracle | Operated by [habedieeh.re](https://www.habedieeh.re) |
| [http://qr.vern.i2p/](http://vernnflenvsqccuanaun7yydnmturi4jkyxlyzhn6ultpje66c3q.b32.i2p/) | Canada | OVHCloud | Operated by [~vern](https://vern.cc) | | [quetre.g4c3eya4clenolymqbpgwz3q3tawoxw56yhzk4vugqrl6dtu3ejvhjid.onion](http://quetre.g4c3eya4clenolymqbpgwz3q3tawoxw56yhzk4vugqrl6dtu3ejvhjid.onion/) | U.S. | BuyVM | Operated by [PrivacyDev](https://privacydev.net/) |
| [quora.cepyxplublbyw2f4axy4pyztfbxmf63lrt2c7uwv6wl4iixz53czload.onion](http://quora.cepyxplublbyw2f4axy4pyztfbxmf63lrt2c7uwv6wl4iixz53czload.onion) | Hungary | N/A (Self-hosted) | Operated by [hnhx](https://femboy.hu) |
| **3. I2P** | | | |
| [qr.vern.i2p/](http://vernnflenvsqccuanaun7yydnmturi4jkyxlyzhn6ultpje66c3q.b32.i2p/) | US | Hetzner | Operated by [~vern](https://vern.cc) |
--- ---
## Comparision ## Comparison
### Speed ### Speed
URL for comparision: https://www.quora.com/How-does-the-Z-boson-decay URL for comparison: https://www.quora.com/How-does-the-Z-boson-decay
| | Quora | Quetre | | | Quora | Quetre |
| --------------- | --------- | -------- | | --------------- | --------- | -------- |
@ -79,13 +105,13 @@ URL for comparision: https://www.quora.com/How-does-the-Z-boson-decay
| Finish time | 2.44min\* | 4.62s | | Finish time | 2.44min\* | 4.62s |
| Data consumed | 3.49MB | 404.47KB | | Data consumed | 3.49MB | 404.47KB |
\*the requests were ongoing even after 6 minutes \*The requests were ongoing even after 6 minutes
--- ---
### Usability ### Usability
- Quora: You can't even see an answer(unless you do some hacks) if you're not signed in. They put a big banner in front of answers to sign you up/in forcefully. - Quora: You can't even see an answer (unless you do some hacks) if you're not signed in. They put a big banner in front of answers to sign you up/in forcefully.
- Quetre: There is no accounts system. Just read whatever you want to read. Zero fuss. - Quetre: There is no accounts system. Just read whatever you want to read. Zero fuss.
@ -93,92 +119,84 @@ URL for comparision: https://www.quora.com/How-does-the-Z-boson-decay
### Privacy ### Privacy
#### Quora(when browsing anonymously) #### Quora (when browsing "anonymously")
From [their privacy policy](https://www.quora.com/about/privacy) From [their privacy policy](https://www.quora.com/about/privacy)
- Technologies used - **Technologies used**
- cookies - Cookies
- log files - Log files
- clear GIFs/pixel tags - Clear GIFs/pixel tags
- JavaScript - JavaScript
- web beacons - Web beacons
- local storage objects - Local Storage Objects
- Analytics Tools - Analytics Tools
- other tracking technologies - Other tracking technologies
- Data collected - **Data collected**
- searches - Searches
- page views - Page views
- date and time of your visit - Date and time of your visit
- browser type - Browser type
- type of computer or mobile device - Type of computer or mobile device
- browser language - Browser language
- IP address - IP address
- mobile carrier - Mobile carrier
- unique device identifier - Unique device identifier
- location - Location
- requested and referring URLs - Requested and referring URLs
- other information about your use of the Quora Platform - Other information about your use of the Quora Platform
#### Quetre #### Quetre
- Data actively collected by Quetre - **Data actively collected by Quetre**
None. None.
- Data passively collected by Quetre - **Data passively collected by Quetre**
Whenever you hit some error page, an error object is logged to the console on the server. That error object contains the resource url you were trying to access, and the usual stack trace. That's it. Whenever you hit some error page, an error object is logged to the console on the server. That error object contains the resource URL you were trying to access, and the usual stack trace. That's it.
- Data stored locally in your browser - **Data stored locally in your browser**
A key called 'theme' is stored in local storage provided by your browser to store your theme preference should you override the default theme. To prevent this behaviour, either disable JavaScript or local storage for Quetre. A key called 'theme' is stored in local storage provided by your browser to store your theme preference should you override the default theme. To prevent this behaviour, either disable JavaScript or local storage for Quetre.
- Data collected by other services
If you're using the official instance(which is deployed on Heroku), Heroku might log your IP to prevent abuse. Also, as Quetre connects to 'cdn.jsdelivr.net' for MathJax library respectively, this services might log some data. So, follow due precaution. Using a VPN might be a good idea. Or even better, consider hosting your own instance.
--- ---
## FAQs ## FAQs
- How do I use this? - **How do I use this?**
Replace 'www.quora.com' in any URL with 'quetre.iket.me'. So, 'https://www.quora.com/Are-Nubians-nilotes' becomes 'https://quetre.iket.me/Are-Nubians-nilotes'. Replace 'www.quora.com' in any URL with 'quetre.iket.me' (or any other instance). So, 'https://www.quora.com/Are-Nubians-nilotes' becomes 'https://quetre.iket.me/Are-Nubians-nilotes'.
- I don't want to edit the URLs manually! - **I don't want to edit the URLs manually!**
There are [a couple of solutions](#automatic-redirection) for that. There are [a couple of solutions](#automatic-redirection) for that.
- There are some unreachable routes. - **There are some unreachable routes.**
I'm working to implement them soon. Keep an eye on [To-Do list](#to-do). I'm working to implement them soon. Keep an eye on [To-Do list](#to-do).
- Why is the website connecting to 'cdn.jsdelivr.net'? - **Why are some math equations showing up weirdly?**
It is for an open source library – [Mathjax](https://www.mathjax.org/) – which is used to display math eqations nicely. If I get enough time, I'll include it locally.
- Why are some math equations showing up weirdly?
If you're browsing with JavaScript disabled, then the Mathjax library isn't able to load and format tex equations. I'd recommend to enable JavaScript for it since there's no other way to show them in the browser. Even Quora uses Mathjax. If you're browsing with JavaScript disabled, then the Mathjax library isn't able to load and format tex equations. I'd recommend to enable JavaScript for it since there's no other way to show them in the browser. Even Quora uses Mathjax.
- Why can I only view a couple of answers? - **Why can I only view a couple of answers?**
Quora doesn't show all answers at once. It only loads more answers as the user scrolls down. Furthermore, it uses many unique IDs to send ajax requests to fetch those answers. So, all in all, getting more answers isn't impossible but quite difficult requiring some serious amount of time on their website in order to figure out how it all happens. I'm short on time for now. Quora doesn't show all answers at once. It only loads more answers as the user scrolls down. Furthermore, it uses many unique IDs to send ajax requests to fetch those answers. So, all in all, getting more answers isn't impossible but quite difficult requiring some serious amount of time on their website in order to figure out how it all happens. I'm short on time for now.
- Why am I getting a _Recheck the URL_ error? - **Why am I getting a _Recheck the URL_ error?**
Sometimes Quora doesn't populate the answer page HTML, and hence, Quetre is unable to extract data from it. If that happens, you can refresh the page a couple of times to get the answers. Sometimes Quora doesn't populate the answer page HTML, and hence, Quetre is unable to extract data from it. If that happens, you can refresh the page a couple of times to get the answers.
- I have some ideas/want to help. - **I have some ideas/want to help.**
You're most welcome to do that. Just [contact me](#contact) or fork [the repo](https://github.com/zyachel/quetre/forkand make a pull request. You can even help by correcting some typos or translating this README to other languages. You're most welcome to do that. Just [contact me](#contact) or fork [the repo](https://github.com/zyachel/quetre/fork) and make a pull request. You can even help by correcting some typos or translating this README to other languages. *Please, read the [CONTRIBUTING.md](./CONTRIBUTING.md) beforehand.*
- Why the name Quetre? - **Why the name Quetre?**
Quora is [supposedly](https://www.quora.com/Why-is-Quora-called-Quora-4a portmanteau of 'Questions or answers'. In the same vein, Quetre is a portmanteau of 'Questions and answers', but [in Latin](https://lingva.ml/en/la/questions%20and%20answers%0A). Quora is [supposedly](https://www.quora.com/Why-is-Quora-called-Quora-4) a portmanteau of 'Questions or answers'. In the same vein, Quetre is a portmanteau of 'Questions and answers', but [in Latin](https://lingva.ml/en/la/questions%20and%20answers%0A).
- I cannot view the comments. Will you add that feature? - **I cannot view the comments. Will you add that feature?**
See [this issue](https://codeberg.org/zyachel/quetre/issues/11) See [this issue](https://codeberg.org/zyachel/quetre/issues/11)
@ -186,12 +204,12 @@ From [their privacy policy](https://www.quora.com/about/privacy)
## To-Do ## To-Do
- [ ] add missing routes like topics, profile, and search - [x] Add missing routes like topics, profile, and search
- [ ] use redis - [ ] Use redis
- [ ] serve images and other assets from Quetre - [x] Serve images and other assets from Quetre
- [x] implement a better installation method - [x] Implement a better installation method
- [ ] implement other trivial routes like a specific answer, spaces, etc. - [ ] Implement other trivial routes like a specific answer, spaces, etc.
- [ ] implement a way to get more answers(not a big priority as of now) - [ ] Implement a way to get more answers (not a big priority as of now)
--- ---
@ -204,47 +222,65 @@ From [their privacy policy](https://www.quora.com/about/privacy)
2. Clone and set up the repository. 2. Clone and set up the repository.
```bash ```bash
git clone https://github.com/zyachel/quetre.git # replace github.com with codeberg.org if you're cloning from there git clone https://github.com/zyachel/quetre.git # Replace github.com with codeberg.org if you're cloning from there
cd quetre cd quetre
cp .env.example .env # you can make any changes here cp .env.example .env # You can make any changes here
# change `pnpm` to `npm run` here as well as in package.json if you use `npm` # Change `pnpm` to `npm run` here as well as in package.json if you use `npm`
pnpm install pnpm install
pnpm start pnpm start
``` ```
Quetre will start running at http://localhost:3000. Quetre will start running at http://localhost:3000.
**Important: If you are running an .onion or I2P instance, set the `NO_UPGRADE` .env variable to "1", otherwise the page will be broken.**
### Docker ### Docker
There is a [docker image](https://github.com/PussTheCat-org/docker-quetre-quay) made by [@TheFrenchGhosty](https://github.com/TheFrenchGhosty) for [PussTheCat.org](https://pussthecat.org/)'s [instance](https://quetre.pussthecat.org/). You can check that out case you want to go the docker way. There is a [docker image](https://github.com/PussTheCat-org/docker-quetre-quay) made by [@TheFrenchGhosty](https://github.com/TheFrenchGhosty) for [PussTheCat.org](https://pussthecat.org/)'s [instance](https://quetre.pussthecat.org/).
If you want a leaner one, you can checkout [@video-prize-ranch](https://codeberg.org/video-prize-ranch)'s [docker image](https://codeberg.org/video-prize-ranch/-/packages/container/quetre/latest).
--- ---
## Contributing
The development may seem slow as I don't have lots of free time. And whenever I do, it gets split between this service and [LibreIMDb](https://github.com/zyachel/libremdb/).
If you believe you can help furthering this project in any way (be it maintaining, fixing issues, or adding features), please [get in touch](#contact).
*Regardless, any type of contribution is always welcome.*
**If you plan on contributing, please read our [CONTRIBUTING.md](./CONTRIBUTING.md) file.**
## Misc ## Misc
### Automatic redirection ### Automatic redirection
Following extensions can be used to automatically redirect Quora URLs to Quetre: The following extensions can be used to automatically redirect Quora URLs to Quetre:
- [redirector](https://github.com/einaregilsson/Redirector) - [redirector](https://github.com/einaregilsson/Redirector)
You can manually add any redirect. You can manually add any redirect.
Below is a basic config of Quora to Quetre. Replace `quetre.herokuapp.com` in `Redirect to` to any instance of your choice. Below is a basic config of Quora to Quetre. Replace `quetre.iket.me` in `Redirect to` to any instance of your choice.
``` ```
Description: Quora to Quetre Description: Quora to Quetre
Example URL: https://www.quora.com/What-is-Linux-4?share=1 Example URL: https://www.quora.com/What-is-Linux-4?share=1
Include pattern: https?:\/\/(www\.)?quora\.com\/([^\?]*) Include pattern: (https:\/\/.{2,}\.quora\.com\/.*)
Redirect to: https://quetre.herokuapp.com/$2 Redirect to: https://quetre.iket.me/redirect/$1
Pattern type: Regular Expression Pattern type: Regular Expression
Pattern description: redirects all Quora urls(excluding language-specific and spaces) to Quetre Pattern description: Redirects all Quora urls to Quetre
``` ```
This config should output: This config should output:
`Example result: https://quetre.herokuapp.com/What-is-Linux-4` `Example result: https://quetre.iket.me/redirect/https://www.quora.com/What-is-Linux-4?share=1`
- [LibRedirect](https://github.com/libredirect/libredirect/) - [LibRedirect](https://github.com/libredirect/libredirect/)
Redirects many popular services to their alternative front-ends. Has a ton of features and an active community. Quetre is supported by default. So, no need to do anything. Redirects many popular services to their alternative front-ends. Has a ton of features and an active community. Quetre is supported by default. So, no need to do anything.
- [Privacy Redirector](https://github.com/dybdeskarphet/privacy-redirector)
A userscript that redirects popular social media platforms to their privacy respecting frontends.
- Other addons with similar functionality:
- [Dynamic Privacy Redirect](https://github.com/PrivacyDevel/DPR-addon)
- [Alter](https://github.com/w3bdev1/alter)
### Other alternative front-ends ### Other alternative front-ends
- [digitalblossom/alternative-frontends](https://github.com/digitalblossom/alternative-frontends): contains other alternative front-ends. - [digitalblossom/alternative-frontends](https://github.com/digitalblossom/alternative-frontends): contains other alternative front-ends.
@ -256,7 +292,7 @@ Following extensions can be used to automatically redirect Quora URLs to Quetre:
### Programming ### Programming
- [JavaScript](https://www.ecma-international.org/technical-committees/tc39/): programming language - [JavaScript](https://www.ecma-international.org/technical-committees/tc39/): Programming language
- [Sass](https://sass-lang.com/): CSS preprocessor - [Sass](https://sass-lang.com/): CSS preprocessor
- [Pug](https://pugjs.org/): Template engine - [Pug](https://pugjs.org/): Template engine
- [Node.js](https://nodejs.org/en/): JS runtime environment - [Node.js](https://nodejs.org/en/): JS runtime environment

7
app.js
View file

@ -20,10 +20,9 @@ const app = express();
// 1. IMPORTANT MIDDLWARES // 1. IMPORTANT MIDDLWARES
app.use(compression()); // compressing responses app.use(compression()); // compressing responses
app.use( app.use(
helmet({ helmet({
contentSecurityPolicy: { contentSecurityPolicy: {
directives: { directives: {
'script-src': ["'self'", 'cdn.jsdelivr.net'],
'block-all-mixed-content': null, // deprecated. 'block-all-mixed-content': null, // deprecated.
'upgrade-insecure-requests': process.env.NO_UPGRADE ? null : [], 'upgrade-insecure-requests': process.env.NO_UPGRADE ? null : [],
}, },
@ -51,9 +50,7 @@ app.use(
if (process.env.NODE_ENV === 'development') app.use(morgan('dev')); // for logging during development if (process.env.NODE_ENV === 'development') app.use(morgan('dev')); // for logging during development
// middleware to add baseUrl to req object // middleware to add baseUrl to req object
app.use((req, res, next) => { app.use((req, res, next) => {
req.urlObj = new URL( req.urlObj = new URL(req.originalUrl, `${req.protocol}://${req.get('host')}`);
`${req.protocol}://${req.get('host')}${req.originalUrl}`
);
next(); next();
}); });

View file

@ -1,8 +0,0 @@
{
"name": "Quetre",
"description": "A libre front-end for Quora.",
"keywords": ["front-end", "quora", "privacy", "foss"],
"website": "https://quetre.herokuapp.com/",
"repository": "https://github.com/zyachel/quetre",
"logo": "https://quetre.herokuapp.com/icon.svg"
}

View file

@ -1,11 +1,13 @@
/* eslint-disable no-unused-vars */
//////////////////////////////////////////////////////// ////////////////////////////////////////////////////////
// IMPORTS // IMPORTS
//////////////////////////////////////////////////////// ////////////////////////////////////////////////////////
import axiosInstance from '../utils/axiosInstance.js'; import getAxiosInstance from '../utils/getAxiosInstance.js';
import catchAsyncErrors from '../utils/catchAsyncErrors.js'; import catchAsyncErrors from '../utils/catchAsyncErrors.js';
import getAnswers from '../fetchers/getAnswers.js'; import getAnswers from '../fetchers/getAnswers.js';
import getTopic from '../fetchers/getTopic.js'; import getTopic from '../fetchers/getTopic.js';
import getProfile from '../fetchers/getProfile.js'; import getProfile from '../fetchers/getProfile.js';
import getSearch from '../fetchers/getSearch.js';
//////////////////////////////////////////////////////// ////////////////////////////////////////////////////////
// EXPORTS // EXPORTS
@ -13,18 +15,24 @@ import getProfile from '../fetchers/getProfile.js';
export const about = (req, res, next) => { export const about = (req, res, next) => {
res.status(200).json({ res.status(200).json({
status: 'success', status: 'success',
message: message: `make a request.
"make a request. available endpoints are: '/some-slug', '/unanswered/some-slug'", available endpoints are: '/slug', '/unanswered/slug', '/topic/slug', '/profile/slug', '/search?q=query', /?q=query.`,
}); });
}; };
export const answers = catchAsyncErrors(async (req, res, next) => { export const answers = catchAsyncErrors(async (req, res, next) => {
const data = await getAnswers(req.params.slug); const { slug } = req.params;
const { lang } = req.query;
const data = await getAnswers(slug, lang);
res.status(200).json({ status: 'success', data }); res.status(200).json({ status: 'success', data });
}); });
export const topic = catchAsyncErrors(async (req, res, next) => { export const topic = catchAsyncErrors(async (req, res, next) => {
const data = await getTopic(req.params.slug); const { slug } = req.params;
const { lang } = req.query;
const data = await getTopic(slug, lang);
res.status(200).json({ status: 'success', data }); res.status(200).json({ status: 'success', data });
}); });
@ -33,6 +41,15 @@ export const profile = catchAsyncErrors(async (req, res, next) => {
res.status(200).json({ status: 'success', data }); res.status(200).json({ status: 'success', data });
}); });
export const search = catchAsyncErrors(async (req, res, next) => {
const searchText = req.urlObj.searchParams.get('q')?.trim(); // no search to perform if there isn't any query
let searchData = null;
if (searchText) searchData = await getSearch(req.urlObj.search);
res.status(200).json({ status: 'success', data: searchData });
});
export const unimplemented = (req, res, next) => { export const unimplemented = (req, res, next) => {
res.status(501).json({ res.status(501).json({
status: 'fail', status: 'fail',
@ -41,17 +58,19 @@ export const unimplemented = (req, res, next) => {
}; };
export const image = catchAsyncErrors(async (req, res, next) => { export const image = catchAsyncErrors(async (req, res, next) => {
if (!req.params.domain.endsWith('quoracdn.net')) { const { domain, path } = req.params;
if (!domain.endsWith('quoracdn.net')) {
return res.status(403).json({ return res.status(403).json({
status: 'fail', status: 'fail',
message: 'Invalid domain', message: 'Invalid domain',
}); });
} }
// changing defaults for this particular endpoint
const axiosInstance = getAxiosInstance();
axiosInstance.defaults.baseURL = `https://${domain}/`;
const imageRes = await axiosInstance.get(path, { responseType: 'stream' });
const imageRes = await axiosInstance.get(
`https://${req.params.domain}/${req.params.path}`,
{ responseType: 'arraybuffer' }
);
res.set('Content-Type', imageRes.headers['content-type']); res.set('Content-Type', imageRes.headers['content-type']);
res.status(200).send(imageRes.data); return imageRes.data.pipe(res);
}); });

View file

@ -1,3 +1,4 @@
/* eslint-disable no-unused-vars */
/* eslint-disable no-param-reassign */ /* eslint-disable no-param-reassign */
//////////////////////////////////////////////////////// ////////////////////////////////////////////////////////
@ -34,9 +35,8 @@ const sendErrorResponse = (err, req, res, devMode = false) => {
}, },
meta: { meta: {
title: 'Error', title: 'Error',
url: `${req.urlObj.origin}${req.urlObj.pathname}`, url: req.urlObj,
imageUrl: `${req.urlObj.origin}/icon.svg`, imageUrl: `${req.urlObj.origin}/icon.svg`,
urlObj: req.urlObj,
description: `ERROR: ${err.message}. Please try again later.`, description: `ERROR: ${err.message}. Please try again later.`,
}, },
}); });

View file

@ -5,8 +5,9 @@
import catchAsyncErrors from '../utils/catchAsyncErrors.js'; import catchAsyncErrors from '../utils/catchAsyncErrors.js';
import getAnswers from '../fetchers/getAnswers.js'; import getAnswers from '../fetchers/getAnswers.js';
import getTopic from '../fetchers/getTopic.js'; import getTopic from '../fetchers/getTopic.js';
import { nonSlugRoutes } from '../utils/constants.js'; import { acceptedLanguages, nonSlugRoutes } from '../utils/constants.js';
import getProfile from '../fetchers/getProfile.js'; import getProfile from '../fetchers/getProfile.js';
import getSearch from '../fetchers/getSearch.js';
//////////////////////////////////////////////////////// ////////////////////////////////////////////////////////
// EXPORTS // EXPORTS
@ -15,7 +16,7 @@ export const about = (req, res, next) => {
res.render('about', { res.render('about', {
meta: { meta: {
title: 'About', title: 'About',
url: `${req.urlObj.origin}${req.urlObj.pathname}`, url: req.urlObj,
imageUrl: `${req.urlObj.origin}/icon.svg`, imageUrl: `${req.urlObj.origin}/icon.svg`,
description: description:
'Quetre is a libre front-end for Quora. See any answer without being tracked, without being required to log in, and without being bombarded by pesky ads.', 'Quetre is a libre front-end for Quora. See any answer without being tracked, without being required to log in, and without being bombarded by pesky ads.',
@ -27,7 +28,7 @@ export const privacy = (req, res, next) => {
res.render('privacy', { res.render('privacy', {
meta: { meta: {
title: 'Privacy', title: 'Privacy',
url: `${req.urlObj.origin}${req.urlObj.pathname}`, url: req.urlObj,
imageUrl: `${req.urlObj.origin}/icon.svg`, imageUrl: `${req.urlObj.origin}/icon.svg`,
description: 'Privacy Policy of Quetre, a libre front-end for Quora.', description: 'Privacy Policy of Quetre, a libre front-end for Quora.',
}, },
@ -36,10 +37,12 @@ export const privacy = (req, res, next) => {
export const answers = catchAsyncErrors(async (req, res, next) => { export const answers = catchAsyncErrors(async (req, res, next) => {
const { slug } = req.params; const { slug } = req.params;
const { lang } = req.query;
// added this so that a request by browser to get favicon doesn't end up being interpreted as a slug // added this so that a request by browser to get favicon doesn't end up being interpreted as a slug
if (nonSlugRoutes.includes(slug)) return next(); if (nonSlugRoutes.includes(slug)) return next();
const answersData = await getAnswers(slug); const answersData = await getAnswers(slug, lang);
const title = answersData.question.text[0].spans const title = answersData.question.text[0].spans
.map(span => span.text) .map(span => span.text)
.join(''); .join('');
@ -48,7 +51,7 @@ export const answers = catchAsyncErrors(async (req, res, next) => {
data: answersData, data: answersData,
meta: { meta: {
title, title,
url: `${req.urlObj.origin}${req.urlObj.pathname}`, url: req.urlObj,
imageUrl: `${req.urlObj.origin}/icon.svg`, imageUrl: `${req.urlObj.origin}/icon.svg`,
description: `Answers to ${title}`, description: `Answers to ${title}`,
}, },
@ -56,13 +59,16 @@ export const answers = catchAsyncErrors(async (req, res, next) => {
}); });
export const topic = catchAsyncErrors(async (req, res, next) => { export const topic = catchAsyncErrors(async (req, res, next) => {
const topicData = await getTopic(req.params.slug); const { slug } = req.params;
const { lang } = req.query;
const topicData = await getTopic(slug, lang);
res.status(200).render('topic', { res.status(200).render('topic', {
data: topicData, data: topicData,
meta: { meta: {
title: topicData.name, title: topicData.name,
url: `${req.urlObj.origin}${req.urlObj.pathname}`, url: req.urlObj,
imageUrl: `${req.urlObj.origin}/icon.svg`, imageUrl: `${req.urlObj.origin}/icon.svg`,
description: `Information about ${topicData.name} topic.`, description: `Information about ${topicData.name} topic.`,
}, },
@ -70,33 +76,70 @@ export const topic = catchAsyncErrors(async (req, res, next) => {
}); });
export const profile = catchAsyncErrors(async (req, res, next) => { export const profile = catchAsyncErrors(async (req, res, next) => {
const profileData = await getProfile(req.params.name); const { name } = req.params;
const { lang } = req.query;
const profileData = await getProfile(name, lang);
res.status(200).render('profile', { res.status(200).render('profile', {
data: profileData, data: profileData,
meta: { meta: {
title: profileData.basic.name, title: profileData.basic.name,
url: `${req.urlObj.origin}${req.urlObj.pathname}`, url: req.urlObj,
imageUrl: `${req.urlObj.origin}/icon.svg`, imageUrl: `${req.urlObj.origin}/icon.svg`,
description: `${profileData.basic.name}'s profile.`, description: `${profileData.basic.name}'s profile.`,
}, },
}); });
}); });
export const unimplemented = (req, res, next) => { export const search = catchAsyncErrors(async (req, res, next) => {
const message = const searchText = req.urlObj.searchParams.get('q')?.trim();
"This route isn't yet implemented. Check back sometime later!"; const { lang } = req.query;
res.status(501).render('error', { let searchData = null;
data: { if (searchText) searchData = await getSearch(req.urlObj.search, lang);
statusCode: 501,
message, res.status(200).render('search', {
data: searchData,
meta: {
title: searchText || 'Search',
url: req.urlObj,
imageUrl: `${req.urlObj.origin}/icon.svg`,
description: searchText ? `results for '${searchText}'` : 'search page',
}, },
});
});
const regex = /^https:\/\/(.{2,})\.quora\.com(\/.*)$/; // local helper constant
export const redirect = (req, res, next) => {
const url = req.originalUrl.replace('/redirect/', ''); // removing `/redirect/` part.
const match = regex.exec(url);
if (!match) return res.redirect('/');
const [_, subdomain, rest] = match; // eg: subdomain: 'es', rest: '/topic/linux?share=1'
let link;
if (acceptedLanguages.includes(subdomain))
// adding lang param
link = `${rest}${rest.includes('?') ? '&' : '?'}lang=${subdomain}`;
else if (subdomain === 'www') link = rest; // doing nothing
else link = `/space/${subdomain}${rest}`; // gotta be a space url.
return res.redirect(link);
};
export const unimplemented = (req, res, next) => {
const data = {
message: "This route isn't yet implemented. Check back sometime later!",
statusCode: 501,
};
res.status(data.statusCode).render('error', {
data,
meta: { meta: {
title: 'Not yet implemented', title: 'Not yet implemented',
url: `${req.urlObj.origin}${req.urlObj.pathname}`, url: req.urlObj,
imageUrl: `${req.urlObj.origin}/icon.svg`, imageUrl: `${req.urlObj.origin}/icon.svg`,
urlObj: req.urlObj, description: data.message,
description: message,
}, },
}); });
}; };

View file

@ -3,45 +3,55 @@
// IMPORTS // IMPORTS
//////////////////////////////////////////////////////// ////////////////////////////////////////////////////////
import * as cheerio from 'cheerio'; import * as cheerio from 'cheerio';
import axiosInstance from '../utils/axiosInstance.js'; import getAxiosInstance from '../utils/getAxiosInstance.js';
import AppError from '../utils/AppError.js'; import AppError from '../utils/AppError.js';
//////////////////////////////////////////////////////// ////////////////////////////////////////////////////////
// FUNCTION // FUNCTION
//////////////////////////////////////////////////////// ////////////////////////////////////////////////////////
/** /**
* * makes a call to quora.com(with the resourceStr appended) and returns parsed JSON containing the data about the resource requested.
* @param {string} resourceStr a string after the baseURL * @param {string} resourceStr a string after the baseURL
* @param {{keyword: string, lang?: string, toEncode?: boolean}} options additional options
* @returns JSON containing the result * @returns JSON containing the result
* @description makes a call to quora.com(with the resourceStr appended) and returns parsed JSON containing the data about the resource requested.
* @example await fetcher('What-is-free-and-open-software'); // will return object containing answers * @example await fetcher('What-is-free-and-open-software'); // will return object containing answers
* await fetcher('topic/Space-Physics'); // will return 'space physics' topic object * await fetcher('topic/Space-Physics'); // will return 'space physics' topic object
* await fetcher('profile/Charlie-Cheever'); // will return object containing information about charlie cheever * await fetcher('profile/Charlie-Cheever'); // will return object containing information about charlie cheever
*/ */
const fetcher = async resourceStr => { const fetcher = async (
resourceStr,
{ keyword, lang, toEncode = true }
) => {
try { try {
// as url might contain unescaped chars. so, encodeing it right away // as url might contain unescaped chars. so, encoding it right away
const res = await axiosInstance.get(encodeURIComponent(resourceStr)); const str = toEncode ? encodeURIComponent(resourceStr) : resourceStr;
const axiosInstance = getAxiosInstance(lang);
const res = await axiosInstance.get(str);
const $ = cheerio.load(res.data); const $ = cheerio.load(res.data);
// this logic is prone to breakage as Quora changes position of the script that includes answers. const regex = new RegExp(`"{\\\\"data\\\\":\\{\\\\"${keyword}.*\\}"`); // equivalent to /"\{\\"data\\":\{\\"searchConnection.*\}"/
// Cur position: 4th from bottom.
const rawData = $('body script:nth-last-child(4)')
.html()
?.match(/"\{.*\}"/m)?.[0];
if (!rawData || !Object.entries(rawData).length) let rawData;
throw new AppError("couldn't retrieve data", 500); $('body script').each((i, el) => {
const extractedVal = $(el).html().match(regex)?.[0];
const data = JSON.parse(rawData); if (extractedVal) {
rawData = extractedVal;
return false; // breaks loop
}
return true;
});
return data; if (!rawData) throw new AppError("couldn't retrieve data", 500);
return JSON.parse(rawData);
} catch (err) { } catch (err) {
if (err.response?.status === 404) throw new AppError('Not found', 404); const statusCode = err.response?.status;
else if (err.response?.status === 429) if (statusCode === 404) throw new AppError('Not found', 404);
else if (statusCode === 429 || statusCode === 403)
throw new AppError( throw new AppError(
'Quora is rate limiting this instance. Consider hosting your own. Instructions are at Github', 'Quora is rate limiting this instance. Try another or host your own.',
503 503
); );
else throw err; else throw err;

View file

@ -3,17 +3,20 @@
//////////////////////////////////////////////////////// ////////////////////////////////////////////////////////
// import log from '../utils/log.js'; // import log from '../utils/log.js';
import AppError from '../utils/AppError.js'; import AppError from '../utils/AppError.js';
import { quetrefy } from '../utils/urlModifiers.js';
import fetcher from './fetcher.js'; import fetcher from './fetcher.js';
//////////////////////////////////////////////////////// ////////////////////////////////////////////////////////
// FUNCTION // FUNCTION
//////////////////////////////////////////////////////// ////////////////////////////////////////////////////////
const getAnswers = async slug => { const KEYWORD = 'question';
const getAnswers = async (slug, lang) => {
// getting data and destructuring it in case it exists // getting data and destructuring it in case it exists
const res = await fetcher(slug); const res = await fetcher(slug, { keyword: KEYWORD, lang });
const { const {
data: { question: rawData }, data: { [KEYWORD]: rawData },
} = JSON.parse(res); } = JSON.parse(res);
if (!rawData) if (!rawData)
@ -42,14 +45,14 @@ const getAnswers = async slug => {
isAnon: ansObj.node.answer.author.isAnon, isAnon: ansObj.node.answer.author.isAnon,
image: ansObj.node.answer.author.profileImageUrl, image: ansObj.node.answer.author.profileImageUrl,
isVerified: ansObj.node.answer.author.isVerified, isVerified: ansObj.node.answer.author.isVerified,
url: ansObj.node.answer.author.profileUrl, url: quetrefy(ansObj.node.answer.author.profileUrl),
name: `${ansObj.node.answer.author.names[0].givenName} ${ansObj.node.answer.author.names[0].familyName}`, name: `${ansObj.node.answer.author.names[0].givenName} ${ansObj.node.answer.author.names[0].familyName}`,
credential: ansObj.node.answer.authorCredential?.translatedString, credential: ansObj.node.answer.authorCredential?.translatedString,
// additionalCredentials: ansObj.node.answer?.credibilityFacts.map(), // additionalCredentials: ansObj.node.answer?.credibilityFacts.map(),
}, },
originalQuestion: { originalQuestion: {
text: JSON.parse(ansObj.node.answer.question.title).sections, text: JSON.parse(ansObj.node.answer.question.title).sections,
url: ansObj.node.answer.question.url, url: quetrefy(ansObj.node.answer.question.url),
qid: ansObj.node.answer.question.qid, qid: ansObj.node.answer.question.qid,
isDeleted: ansObj.node.answer.question.isDeleted, isDeleted: ansObj.node.answer.question.isDeleted,
}, },
@ -59,7 +62,7 @@ const getAnswers = async slug => {
const data = { const data = {
question: { question: {
text: JSON.parse(rawData.title).sections, text: JSON.parse(rawData.title).sections,
url: rawData.url, url: quetrefy(rawData.url),
qid: rawData.qid, qid: rawData.qid,
idDeleted: rawData.isDeleted, idDeleted: rawData.isDeleted,
isViewable: rawData.isVisibleToViewer, isViewable: rawData.isVisibleToViewer,
@ -70,12 +73,12 @@ const getAnswers = async slug => {
topics: rawData.topics.map(topicObj => ({ topics: rawData.topics.map(topicObj => ({
tid: topicObj.tid, tid: topicObj.tid,
name: topicObj.name, name: topicObj.name,
url: topicObj.url, url: quetrefy(topicObj.url),
})), })),
relatedQuestions: rawData.bottomRelatedQuestionsInfo.relatedQuestions.map( relatedQuestions: rawData.bottomRelatedQuestionsInfo.relatedQuestions.map(
questionObj => ({ questionObj => ({
qid: questionObj.qid, qid: questionObj.qid,
url: questionObj.url, url: quetrefy(questionObj.url),
text: JSON.parse(questionObj.title).sections, text: JSON.parse(questionObj.title).sections,
}) })
), ),

View file

@ -2,6 +2,7 @@
// IMPORTS // IMPORTS
//////////////////////////////////////////////////////// ////////////////////////////////////////////////////////
import AppError from '../utils/AppError.js'; import AppError from '../utils/AppError.js';
import { quetrefy } from '../utils/urlModifiers.js';
import fetcher from './fetcher.js'; import fetcher from './fetcher.js';
//////////////////////////////////////////////////////// ////////////////////////////////////////////////////////
@ -28,14 +29,14 @@ const feedAnswerCleaner = answer => ({
isAnon: answer.author.isAnon, isAnon: answer.author.isAnon,
image: answer.author.profileImageUrl, image: answer.author.profileImageUrl,
isVerified: answer.author.isVerified, isVerified: answer.author.isVerified,
url: answer.author.profileUrl, url: quetrefy(answer.author.profileUrl),
name: `${answer.author.names[0].givenName} ${answer.author.names[0].familyName}`, name: `${answer.author.names[0].givenName} ${answer.author.names[0].familyName}`,
credential: answer.authorCredential?.translatedString, credential: answer.authorCredential?.translatedString,
// additionalCredentials: answer?.credibilityFacts.map(), // additionalCredentials: answer?.credibilityFacts.map(),
}, },
originalQuestion: { question: {
text: JSON.parse(answer.question.title).sections, text: JSON.parse(answer.question.title).sections,
url: answer.question.url, url: quetrefy(answer.question.url),
qid: answer.question.qid, qid: answer.question.qid,
isDeleted: answer.question.isDeleted, isDeleted: answer.question.isDeleted,
}, },
@ -45,7 +46,7 @@ const feedPostCleaner = post => ({
isPinned: post.isPinned, isPinned: post.isPinned,
pid: post.pid, pid: post.pid,
isViewable: post.viewerHasAccess, isViewable: post.viewerHasAccess,
url: post.url, url: quetrefy(post.url),
title: JSON.parse(post.title).sections, title: JSON.parse(post.title).sections,
isDeleted: post.isDeleted, isDeleted: post.isDeleted,
text: JSON.parse(post.content).sections, text: JSON.parse(post.content).sections,
@ -61,14 +62,14 @@ const feedPostCleaner = post => ({
image: post.author.profileImageUrl, image: post.author.profileImageUrl,
isVerified: post.author.isVerified, isVerified: post.author.isVerified,
isPlusUser: post.author.consumerBundleActive, isPlusUser: post.author.consumerBundleActive,
url: post.author.profileUrl, url: quetrefy(post.author.profileUrl),
name: `${post.author.names[0].givenName} ${post.author.names[0].familyName}`, name: `${post.author.names[0].givenName} ${post.author.names[0].familyName}`,
credential: post.authorCredential?.translatedString, credential: post.authorCredential?.translatedString,
}, },
...(post.tribeItem && { ...(post.tribeItem && {
space: { space: {
name: post.tribeItem.tribe.nameString, name: post.tribeItem.tribe.nameString,
url: post.tribeItem.tribe.url, url: quetrefy(post.tribeItem.tribe.url),
image: post.tribeItem.tribe.iconRetinaUrl, image: post.tribeItem.tribe.iconRetinaUrl,
description: post.tribeItem.descriptionString, description: post.tribeItem.descriptionString,
numFollowers: post.tribeItem.tribe.numFollowers, numFollowers: post.tribeItem.tribe.numFollowers,
@ -79,7 +80,7 @@ const feedQuestionCleaner = question => ({
type: 'question', type: 'question',
text: JSON.parse(question.title).sections, text: JSON.parse(question.title).sections,
qid: question.qid, qid: question.qid,
url: question.url, url: quetrefy(question.url),
isDeleted: question.isDeleted, isDeleted: question.isDeleted,
numFollowers: question.followerCount, numFollowers: question.followerCount,
creationTime: question.creationTime, creationTime: question.creationTime,
@ -105,12 +106,14 @@ const feedCleaner = feed => {
//////////////////////////////////////////////////////// ////////////////////////////////////////////////////////
// FUNCTION // FUNCTION
//////////////////////////////////////////////////////// ////////////////////////////////////////////////////////
const getProfile = async slug => { const KEYWORD = 'user';
const getProfile = async (slug, lang) => {
// getting data and destructuring it in case it exists // getting data and destructuring it in case it exists
const res = await fetcher(`profile/${slug}`); const res = await fetcher(`profile/${slug}`, { keyword: KEYWORD, lang });
const { const {
data: { user: rawData }, data: { [KEYWORD]: rawData },
} = JSON.parse(res); } = JSON.parse(res);
if (!rawData) if (!rawData)
@ -125,7 +128,7 @@ const getProfile = async slug => {
uid: rawData.uid, uid: rawData.uid,
image: rawData.profileImageUrl, image: rawData.profileImageUrl,
name: `${rawData.names[0].givenName} ${rawData.names[0].familyName}`, name: `${rawData.names[0].givenName} ${rawData.names[0].familyName}`,
profile: rawData.profileUrl, profile: quetrefy(rawData.profileUrl),
isDeceased: rawData.isDeceased, isDeceased: rawData.isDeceased,
isBusiness: rawData.businessStatus, isBusiness: rawData.businessStatus,
isBot: rawData.isUserBot, isBot: rawData.isUserBot,
@ -184,7 +187,7 @@ const getProfile = async slug => {
numFollowingSpaces: rawData.numFollowedTribes, numFollowingSpaces: rawData.numFollowedTribes,
spaces: rawData.followingTribesConnection.edges.map(space => ({ spaces: rawData.followingTribesConnection.edges.map(space => ({
numItems: space.node.numItemsOfUser, numItems: space.node.numItemsOfUser,
url: space.node.url, url: quetrefy(space.node.url),
name: space.node.nameString, name: space.node.nameString,
image: space.node.iconRetinaUrl, image: space.node.iconRetinaUrl,
isSensitive: space.node.isSensitive, isSensitive: space.node.isSensitive,
@ -194,7 +197,7 @@ const getProfile = async slug => {
numFollowingTopics: rawData.numFollowedTopics, numFollowingTopics: rawData.numFollowedTopics,
topics: rawData.expertiseTopicsConnection.edges.map(topic => ({ topics: rawData.expertiseTopicsConnection.edges.map(topic => ({
name: topic.node.name, name: topic.node.name,
url: topic.node.url, url: quetrefy(topic.node.url),
isSensitive: topic.node.isSensitive, isSensitive: topic.node.isSensitive,
numFollowers: topic.node.numFollowers, numFollowers: topic.node.numFollowers,
image: topic.node.photoUrl, image: topic.node.photoUrl,

169
fetchers/getSearch.js Normal file
View file

@ -0,0 +1,169 @@
////////////////////////////////////////////////////////
// IMPORTS
////////////////////////////////////////////////////////
import AppError from '../utils/AppError.js';
import fetcher from './fetcher.js';
import { quetrefy } from '../utils/urlModifiers.js';
////////////////////////////////////////////////////////
// HELPER FUNCTIONS
////////////////////////////////////////////////////////
const topicCleaner = topic => ({
type: 'topic',
url: quetrefy(topic.url),
name: topic.name,
numFollowers: topic.numFollowers,
image: topic.photoUrl,
isSensitive: topic.isSensitive,
});
const spaceCleaner = space => ({
type: 'space',
numUsers: space.tribeUserCount,
url: quetrefy(space.url),
name: space.nameString,
description: space.descriptionString,
image: space.iconRetinaUrl,
isSensitive: space.isSensitive,
});
const profileCleaner = profile => ({
type: 'profile',
credential: profile.bestCredential?.translatedString,
isAnon: profile.isAnon,
name: `${profile.names[0]?.givenName} ${profile.names[0]?.familyName}`,
url: quetrefy(profile.profileUrl),
image: profile.profileImageUrl,
numFollowers: profile.followerCount,
isVerified: profile.isVerified,
isBusiness: profile.businessStatus,
isPlusUser: profile.consumerBundleActive,
});
const questionCleaner = question => ({
type: 'question',
text: JSON.parse(question.title).sections,
url: quetrefy(question.url),
isDeleted: question.isDeleted,
numFollowers: question.followerCount,
creationTime: question.creationTime,
numComments: question.numDisplayComments,
isSensitive: question.isSensitive,
});
const answerCleaner = ({ question, previewAnswer: answer }) => ({
type: 'answer',
question: {
...questionCleaner(question),
},
...(answer.originalQuestionIfDifferent && {
originalQuestion: {
text: JSON.parse(answer.originalQuestionIfDifferent.question.title)
.sections,
url: quetrefy(answer.originalQuestionIfDifferent.question.url),
qid: answer.originalQuestionIfDifferent.question.qid,
},
}),
isViewable: !!answer.viewerHasAccess,
text: JSON.parse(answer.content).sections,
creationTime: answer.creationTime,
updatedTime: answer.updatedTime,
numComments: answer.numDisplayComments,
numUpvotes: answer.numUpvotes,
numViews: answer.numViews,
numShares: answer.numSharers,
numAnswerRequests: answer.numRequesters,
isBusinessAnswer: answer.businessAnswer,
url: quetrefy(answer.url),
isSensitive: answer.isSensitive,
author: {
uid: answer.author.uid,
isAnon: answer.author.isAnon,
image: answer.author.profileImageUrl,
isVerified: answer.author.isVerified,
isPlusUser: answer.author.consumerBundleActive,
url: quetrefy(answer.author.profileUrl),
name: `${answer.author.names[0].givenName} ${answer.author.names[0].familyName}`,
credential: answer.authorCredential?.translatedString,
},
});
const postCleaner = post => ({
type: 'post',
pid: post.pid,
isViewable: post.viewerHasAccess,
url: quetrefy(post.url),
title: JSON.parse(post.title).sections,
isDeleted: post.isDeleted,
isSensitive: post.isSensitive,
text: JSON.parse(post.content).sections,
creationTime: post.creationTime,
updatedTime: post.updatedTime,
numComments: post.numDisplayComments,
numUpvotes: post.numUpvotes,
numViews: post.numViews,
numShares: post.numSharers,
author: {
uid: post.author.uid,
isAnon: post.author.isAnon,
image: post.author.profileImageUrl,
isVerified: post.author.isVerified,
isPlusUser: post.author.consumerBundleActive,
url: quetrefy(post.author.profileUrl),
name: `${post.author.names[0].givenName} ${post.author.names[0].familyName}`,
credential: post.authorCredential?.translatedString,
},
...(post.tribeItem && {
space: {
isSensitive: post.tribeItem.tribe.isSensitive,
name: post.tribeItem.tribe.nameString,
url: quetrefy(post.tribeItem.tribe.url),
image: post.tribeItem.tribe.iconRetinaUrl,
description: post.tribeItem.descriptionString,
numFollowers: post.tribeItem.tribe.numFollowers,
},
}),
});
const resultsCleaner = results => {
const cleanedResults = results.map(result => {
const resultToClean = result.node;
if (resultToClean.topic) return topicCleaner(resultToClean.topic);
if (resultToClean.tribe) return spaceCleaner(resultToClean.tribe);
if (resultToClean.post) return postCleaner(resultToClean.post);
if (resultToClean.user) return profileCleaner(resultToClean.user);
if (resultToClean.previewAnswer) return answerCleaner(resultToClean);
if (resultToClean.question) return questionCleaner(resultToClean.question);
return {};
});
return cleanedResults;
};
////////////////////////////////////////////////////////
// FUNCTION
////////////////////////////////////////////////////////
const KEYWORD = 'searchConnection';
const getSearch = async (querySlug, lang) => {
const options = { keyword: KEYWORD, lang, toEncode: false };
const res = await fetcher(`search/${querySlug}`, options);
const {
data: { [KEYWORD]: rawData },
} = JSON.parse(res);
if (!rawData)
throw new AppError(
"Search couldn't be done. Recheck the URL, or resend the request if you believe the URL is correct.",
404
);
const data = {
results: resultsCleaner(rawData.edges),
};
return data;
};
////////////////////////////////////////////////////////
// EXPORTS
////////////////////////////////////////////////////////
export default getSearch;

View file

@ -2,17 +2,21 @@
// IMPORTS // IMPORTS
//////////////////////////////////////////////////////// ////////////////////////////////////////////////////////
import AppError from '../utils/AppError.js'; import AppError from '../utils/AppError.js';
import { quetrefy } from '../utils/urlModifiers.js';
import fetcher from './fetcher.js'; import fetcher from './fetcher.js';
//////////////////////////////////////////////////////// ////////////////////////////////////////////////////////
// FUNCTION // FUNCTION
//////////////////////////////////////////////////////// ////////////////////////////////////////////////////////
const getTopic = async slug => {
const KEYWORD = 'topic';
const getTopic = async (slug, lang) => {
// getting data and destructuring it in case it exists, else throwing an error // getting data and destructuring it in case it exists, else throwing an error
const res = await fetcher(`topic/${slug}`); const res = await fetcher(`topic/${slug}`, { keyword: KEYWORD, lang });
const { const {
data: { topic: rawData }, data: { [KEYWORD]: rawData },
} = JSON.parse(res); } = JSON.parse(res);
if (!rawData) if (!rawData)
@ -24,7 +28,7 @@ const getTopic = async slug => {
const data = { const data = {
tid: rawData.tid, tid: rawData.tid,
name: rawData.name, name: rawData.name,
url: rawData.url, url: quetrefy(rawData.url),
image: rawData.photoUrl, image: rawData.photoUrl,
aliases: rawData.aliases, aliases: rawData.aliases,
numFollowers: rawData.numFollowers, numFollowers: rawData.numFollowers,
@ -34,7 +38,7 @@ const getTopic = async slug => {
mostViewedAuthors: rawData.mostViewedAuthors.map(author => ({ mostViewedAuthors: rawData.mostViewedAuthors.map(author => ({
uid: author.user.uid, uid: author.user.uid,
name: `${author.user.names[0].givenName} ${author.user.names[0].familyName}`, name: `${author.user.names[0].givenName} ${author.user.names[0].familyName}`,
profile: author.user.profileUrl, profile: quetrefy(author.user.profileUrl),
image: author.user.profileImageUrl, image: author.user.profileImageUrl,
isAnon: author.user.isAnon, isAnon: author.user.isAnon,
isVerified: author.user.isVerified, isVerified: author.user.isVerified,
@ -46,7 +50,7 @@ const getTopic = async slug => {
relatedTopics: rawData.relatedTopics.map(topic => ({ relatedTopics: rawData.relatedTopics.map(topic => ({
tid: topic.tid, tid: topic.tid,
name: topic.name, name: topic.name,
url: topic.url, url: quetrefy(topic.url),
image: topic.photoUrl, image: topic.photoUrl,
numFollowers: topic.numFollowers, numFollowers: topic.numFollowers,
})), })),

View file

@ -1,6 +1,6 @@
{ {
"name": "quetre", "name": "quetre",
"version": "4.0.0", "version": "5.5.0",
"description": "a libre front-end for Quora", "description": "a libre front-end for Quora",
"private": true, "private": true,
"type": "module", "type": "module",
@ -10,9 +10,9 @@
"sass:build": "sass views/sass/main.scss:public/css/styles.css --style=compressed", "sass:build": "sass views/sass/main.scss:public/css/styles.css --style=compressed",
"server:dev": "NODE_ENV=development nodemon server.js", "server:dev": "NODE_ENV=development nodemon server.js",
"server:prod": "nodemon server.js", "server:prod": "nodemon server.js",
"dev": "(pnpm sass:watch) & (pnpm server:dev)", "dev": "pnpm sass:watch & pnpm server:dev",
"prod": "(pnpm sass:build) & (pnpm server:prod)", "prod": "pnpm sass:build && pnpm server:prod",
"start": "(npm run sass:build) & (node server.js)" "start": "pnpm run sass:build && node server.js"
}, },
"repository": { "repository": {
"type": "git", "type": "git",
@ -31,22 +31,22 @@
"axios": "^0.27.2", "axios": "^0.27.2",
"cheerio": "1.0.0-rc.12", "cheerio": "1.0.0-rc.12",
"compression": "^1.7.4", "compression": "^1.7.4",
"dotenv": "^16.0.1", "dotenv": "^16.0.3",
"express": "^4.18.1", "express": "^4.18.2",
"helmet": "^5.1.0", "helmet": "^5.1.1",
"morgan": "^1.10.0", "morgan": "^1.10.0",
"pug": "^3.0.2", "pug": "^3.0.2",
"sass": "^1.54.0" "sass": "^1.57.1"
}, },
"devDependencies": { "devDependencies": {
"@eslint/create-config": "^0.3.0", "@eslint/create-config": "^0.3.1",
"@types/express": "^4.17.13", "@types/express": "^4.17.15",
"eslint": "^8.20.0", "eslint": "^8.31.0",
"eslint-config-airbnb-base": "^15.0.0", "eslint-config-airbnb-base": "^15.0.0",
"eslint-config-prettier": "^8.5.0", "eslint-config-prettier": "^8.6.0",
"eslint-plugin-import": "^2.26.0", "eslint-plugin-import": "^2.26.0",
"nodemon": "^2.0.19", "nodemon": "^2.0.20",
"prettier": "^2.7.1" "prettier": "^2.8.2"
}, },
"nodemonConfig": { "nodemonConfig": {
"ignore": [ "ignore": [

631
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

View file

@ -48,3 +48,8 @@ btnTheme.addEventListener('click', () => {
if (userPrefersTheme) setTheme(userPrefersTheme); if (userPrefersTheme) setTheme(userPrefersTheme);
else if (browserPrefersDarkTheme) setTheme('dark'); else if (browserPrefersDarkTheme) setTheme('dark');
})(); })();
////////////////////////////////////////////////////////
// MATHJAX CONFIG
////////////////////////////////////////////////////////
window.MathJax = { options: { enableMenu: false } };

File diff suppressed because one or more lines are too long

View file

@ -1,4 +1,6 @@
<svg width="0" height="0" class="hidden"> <svg width="0" height="0" class="hidden">
<!-- TODO: readd links to fontawesome svg icons. They're ones that don't have viewBox of '0 0 24 24' -->
<!--main logo--> <!--main logo-->
<symbol aria-hidden="true" focusable="false" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 189.91 130.7" id="icon-logo"> <symbol aria-hidden="true" focusable="false" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 189.91 130.7" id="icon-logo">
<g transform="translate(-6.7989 -97.001)"> <g transform="translate(-6.7989 -97.001)">
@ -111,6 +113,12 @@
<symbol aria-hidden="true" focusable="false" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" id="icon-quoraplus"> <symbol aria-hidden="true" focusable="false" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" id="icon-quoraplus">
<path d="M12,1L9,9L1,12L9,15L12,23L15,15L23,12L15,9L12,1Z"></path> <path d="M12,1L9,9L1,12L9,15L12,23L15,15L23,12L15,9L12,1Z"></path>
</symbol> </symbol>
<symbol aria-hidden="true" focusable="false" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" id="icon-search">
<path d="M9.5,3A6.5,6.5 0 0,1 16,9.5C16,11.11 15.41,12.59 14.44,13.73L14.71,14H15.5L20.5,19L19,20.5L14,15.5V14.71L13.73,14.44C12.59,15.41 11.11,16 9.5,16A6.5,6.5 0 0,1 3,9.5A6.5,6.5 0 0,1 9.5,3M9.5,5C7,5 5,7 5,9.5C5,12 7,14 9.5,14C12,14 14,12 14,9.5C14,7 12,5 9.5,5Z"></path>
</symbol>
<symbol aria-hidden="true" focusable="false" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" id="icon-cross">
<path d="M13.46,12L19,17.54V19H17.54L12,13.46L6.46,19H5V17.54L10.54,12L5,6.46V5H6.46L12,10.54L17.54,5H19V6.46L13.46,12Z"></path>
</symbol>
<!-- not yet used --> <!-- not yet used -->
<symbol aria-hidden="true" focusable="false" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" id="icon-cancel"> <symbol aria-hidden="true" focusable="false" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" id="icon-cancel">

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Before After
Before After

View file

@ -6,16 +6,19 @@ import {
topic, topic,
image, image,
profile, profile,
search,
} from '../controllers/apiController.js'; } from '../controllers/apiController.js';
const apiRouter = express.Router(); const apiRouter = express.Router();
apiRouter.get('/', about); apiRouter.get('/(|search)', search);
apiRouter.get('/search', unimplemented); apiRouter.get('/about', about);
apiRouter.get('/image/:domain/:path', image); apiRouter.get('/image/:domain/:path', image);
apiRouter.get('/profile/:name', profile); apiRouter.get('/profile/:name', profile);
apiRouter.get('/topic/:slug', topic); apiRouter.get('/topic/:slug', topic);
apiRouter.get('/unanswered/:slug', answers); apiRouter.get('/unanswered/:slug', answers);
apiRouter.get('/space/:name', unimplemented);
apiRouter.get('/space/:name/:slug', unimplemented);
apiRouter.get('/:slug', answers); apiRouter.get('/:slug', answers);
export default apiRouter; export default apiRouter;

View file

@ -6,16 +6,21 @@ import {
topic, topic,
unimplemented, unimplemented,
profile, profile,
search,
redirect,
} from '../controllers/viewController.js'; } from '../controllers/viewController.js';
const viewRouter = express.Router(); const viewRouter = express.Router();
viewRouter.get('/', about); viewRouter.get('/(|search)', search); // search on / or /search
viewRouter.get('/about', about);
viewRouter.get('/privacy', privacy); viewRouter.get('/privacy', privacy);
viewRouter.get('/search', unimplemented);
viewRouter.get('/profile/:name', profile); viewRouter.get('/profile/:name', profile);
viewRouter.get('/topic/:slug', topic); viewRouter.get('/topic/:slug', topic);
viewRouter.get('/unanswered/:slug', answers); viewRouter.get('/unanswered/:slug', answers);
viewRouter.get('/space/:name', unimplemented);
viewRouter.get('/space/:name/:slug', unimplemented);
viewRouter.get('/:slug', answers); viewRouter.get('/:slug', answers);
viewRouter.get('/redirect/*', redirect); // eg: /redirect/https://www.quora.com/topic/linux
export default viewRouter; export default viewRouter;

View file

@ -1,6 +1,7 @@
//////////////////////////////////////////////////////// ////////////////////////////////////////////////////////
// IMPORTS // IMPORTS
//////////////////////////////////////////////////////// ////////////////////////////////////////////////////////
// eslint-disable-next-line no-unused-vars
import dotenv from 'dotenv/config'; // importing .env vars import dotenv from 'dotenv/config'; // importing .env vars
import app from './app.js'; import app from './app.js';
import log from './utils/log.js'; import log from './utils/log.js';

View file

@ -1,28 +0,0 @@
////////////////////////////////////////////////////////
// IMPORTS
////////////////////////////////////////////////////////
import axios from 'axios';
////////////////////////////////////////////////////////
// FUNCTION
////////////////////////////////////////////////////////
/**
* @description an axios instance having base url already set
*/
const axiosInstance = axios.create({
baseURL: 'https://www.quora.com',
// conditionally adding headers to the request config using ES6 spreading and short-circuiting
headers: {
...(process.env.AXIOS_USER_AGENT && {
'User-Agent': process.env.AXIOS_USER_AGENT,
}),
...(process.env.ACCEPT && {
Accept: process.env.ACCEPT,
}),
},
});
////////////////////////////////////////////////////////
// EXPORTS
////////////////////////////////////////////////////////
export default axiosInstance;

View file

@ -2,10 +2,45 @@
// EXPORTS // EXPORTS
//////////////////////////////////////////////////////// ////////////////////////////////////////////////////////
// some routes are accidentally thought of as slug for answered question. filtering those here. /**
* some routes are accidentally thought of as slug for answered question. filtering those here.
*/
export const nonSlugRoutes = [ export const nonSlugRoutes = [
'favicon.ico', 'favicon.ico',
'apple-touch-icon.png', 'apple-touch-icon.png',
'site.webmanifest', 'site.webmanifest',
'icon.svg', 'icon.svg',
]; ];
/**
* array of languages supported.
*
* see {@link https://help.quora.com/hc/en-us/articles/360015662751-What-languages-does-Quora-support- this help question} and {@link https://loc.gov/standards/iso639-2/ISO-639-2_utf-8.txt this list} for more.
*
*/
export const acceptedLanguages = [
'en', // English
'es', // Spanish
'fr', // French
'de', // German
'it', // Italian
'ja', // Japanese
'id', // Indonesian
'pt', // Portuguese
'hi', // Hindi
'nl', // Dutch
'da', // Danish
'fi', // Finnish
'nb', // Norwegian
'sv', // Swedish
'mr', // Marathi
'bn', // Bengali
'ta', // Tamil
'ar', // Arabic
'he', // Hebrew
'gu', // Gujarati
'kn', // Kannada
'ml', // Malayalam
'te', // Telugu
'po', // Polish
];

31
utils/getAxiosInstance.js Normal file
View file

@ -0,0 +1,31 @@
////////////////////////////////////////////////////////
// IMPORTS
////////////////////////////////////////////////////////
import axios from 'axios';
////////////////////////////////////////////////////////
// FUNCTION
////////////////////////////////////////////////////////
/**
* @description an axios instance having base url already set
* @param {string} lang language to use. default is english.
* @returns AxiosInstance
*/
const getAxiosInstance = (subdomain = 'www') =>
axios.create({
baseURL: `https://${subdomain}.quora.com`,
// conditionally adding headers to the request config using ES6 spreading and short-circuiting
headers: {
...(process.env.AXIOS_USER_AGENT && {
'User-Agent': process.env.AXIOS_USER_AGENT,
}),
...(process.env.ACCEPT && {
Accept: process.env.ACCEPT,
}),
},
});
////////////////////////////////////////////////////////
// EXPORTS
////////////////////////////////////////////////////////
export default getAxiosInstance;

View file

@ -4,7 +4,7 @@
/** /**
* *
* @param {string | {}} toLog stuff to log * @param {string | object} toLog stuff to log
* @param {'success'| 'error'} type optional type param to color the log accordingly * @param {'success'| 'error'} type optional type param to color the log accordingly
* @description logs color coded stuff to the stdout so that it's easily distinguishable * @description logs color coded stuff to the stdout so that it's easily distinguishable
*/ */
@ -27,6 +27,7 @@ function log(toLog, type = null) {
} }
// actually logging to the console // actually logging to the console
// eslint-disable-next-line no-console
console.log( console.log(
`\u001b[${data.colorCode}m ${data.emoji} ${data.message}\n${data.stack} \u001b[39m` `\u001b[${data.colorCode}m ${data.emoji} ${data.message}\n${data.stack} \u001b[39m`
); );

23
utils/urlModifiers.js Normal file
View file

@ -0,0 +1,23 @@
/* eslint-disable import/prefer-default-export */
import { acceptedLanguages } from './constants.js';
/**
* modifies link to be quetre-friendly.
* @param {string} url the quora url to transform. could be relative or absolute
*/
export const quetrefy = url => {
try {
const link = new URL(url);
const subdomain = link.hostname.split('.')[0];
// normal url
if (subdomain === 'www') return link.pathname;
// lang specific route
if (acceptedLanguages.includes(subdomain))
return `${link.pathname}?lang=${subdomain}`;
// must be spaces link
return `/space/${subdomain}${link.pathname}`;
} catch {
// must be a relative url
return url;
}
};

View file

@ -38,19 +38,18 @@ html(lang='en')
- let someAnswerContainsMath = false; - let someAnswerContainsMath = false;
body.body body.body
-if (title !=='About') -if (meta.title !=='About')
a.skip-link(href="#main") Skip to main content a.skip-link(href="#main") Skip to main content
//- MAIN CONTENT GOES HERE //- MAIN CONTENT GOES HERE
include layout/_header include layout/_header
block content block content
p placeholder text
include layout/_footer include layout/_footer
//- including mathjax script only when some answer has math expressions(using the var from above) //- including mathjax script only when some answer has math expressions(using the var from above)
if someAnswerContainsMath if someAnswerContainsMath
script#MathJax-script( script#MathJax-script(
type='text/javascript', type='text/javascript',
src='https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-chtml.js', src='/mathjax/tex-chtml.js',
async async
) )

View file

@ -3,19 +3,19 @@
//-////////////////////////////////////////////////////// //-//////////////////////////////////////////////////////
footer.footer(class=`${meta.title ==='About' ? 'footer__about' : ''}`) footer.footer(class=`${meta.title ==='About' ? 'footer__about' : ''}`)
block footer block footer
//- EXTRA STUFF GOES HERE IF THE PAGE IS ABOUT PAGE //- EXTRA STUFF GOES HERE IF THE PAGE IS ABOUT PAGE
//- NAVIGATION //- NAVIGATION
nav.footer__nav-box(aria-label='Primary navigation') nav.footer__nav-box(aria-label='Primary navigation')
ul.footer__nav ul.footer__nav
- if (meta.title !=='About') - if (meta.title !=='About')
li.footer__nav-item: a.footer__nav-link.footer__link(href="/") About li.footer__nav-item: a.footer__nav-link.footer__link(href="/about") About
li.footer__nav-item: a.footer__nav-link.footer__link(href="/search") Search
li.footer__nav-item: a.footer__nav-link.footer__link(href="https://github.com/zyachel/quetre") Source Code li.footer__nav-item: a.footer__nav-link.footer__link(href="https://github.com/zyachel/quetre") Source Code
li.footer__nav-item: a.footer__nav-link.footer__link(href="/privacy") Privacy li.footer__nav-item: a.footer__nav-link.footer__link(href="/privacy") Privacy
li.footer__nav-item: a.footer__nav-link.footer__link(href="#") Back to top li.footer__nav-item: a.footer__nav-link.footer__link(href="#") Back to top
//- LICENSE //- LICENSE
p.footer__license Licensed under&nbsp; p.footer__license Licensed under&nbsp;
a.footer__link(href="https://www.gnu.org/licenses/agpl-3.0.html") GNU AGPLv3 a.footer__link(href="https://www.gnu.org/licenses/agpl-3.0.html") GNU AGPLv3

View file

@ -7,11 +7,14 @@ header.header(class=`${meta.title === 'About' ? 'header__about': ''}`)
a.header__link.header__logo(href='/') Quetre a.header__link.header__logo(href='/') Quetre
//- for nav on about page //- for nav on about page
block header__nav block header__nav
//- BUTTON FOR CHANGING THEME //- BUTTON FOR CHANGING THEME
button.button.theme-changer.header__theme(aria-label='Change Theme') .header__misc
svg.icon.icon__theme.theme-changer__icon.theme-changer__icon--sun: use(href='/misc/sprite.svg#icon-sun') a.link.header__search(href="/search", aria-label='search page')
svg.icon.icon__theme.theme-changer__icon.theme-changer__icon--moon: use(href='/misc/sprite.svg#icon-moon') svg.icon: use(href='/misc/sprite.svg#icon-search')
button.button.theme-changer.header__theme(aria-label='Change Theme')
svg.icon.icon__theme.theme-changer__icon.theme-changer__icon--sun: use(href='/misc/sprite.svg#icon-sun')
svg.icon.icon__theme.theme-changer__icon.theme-changer__icon--moon: use(href='/misc/sprite.svg#icon-moon')
//- IF THE PAGE IS ABOUT PAGE, THE BLOCK BELOW WILL GET POPULATED //- IF THE PAGE IS ABOUT PAGE, THE BLOCK BELOW WILL GET POPULATED
block header__info block header__info

View file

@ -1,18 +1,18 @@
//-////////////////////////////////////////////////////// //-//////////////////////////////////////////////////////
//- INCLUDES/EXTENDS //- INCLUDES/EXTENDS
//-////////////////////////////////////////////////////// //-//////////////////////////////////////////////////////
include ../mixins/_formatText include _formatText
include ../mixins/_metadata include _metadata
include ../mixins/_utils include _utils
//-////////////////////////////////////////////////////// //-//////////////////////////////////////////////////////
//- MAIN CONTENT //- MAIN CONTENT
//-////////////////////////////////////////////////////// //-//////////////////////////////////////////////////////
mixin addAnswer(answer) mixin addAnswer(answer, includeQuestion=false)
article.answer article.answer
//- ABOUT AUTHOR //- ABOUT AUTHOR
figure.metadata-primary .metadata-primary
figcaption.metadata-primary__heading p.metadata-primary__heading
if answer.author.isAnon if answer.author.isAnon
span Anonymous span Anonymous
else else
@ -21,14 +21,20 @@ mixin addAnswer(answer)
svg.icon.metadata-primary__icon svg.icon.metadata-primary__icon
title verified title verified
use(href='/misc/sprite.svg#icon-verified') use(href='/misc/sprite.svg#icon-verified')
img.metadata-primary__image(src=answer.author.image.replace('https://', "/api/v1/image/"), alt=`${answer.author.name}'s profile photo`, loading='lazy') +proxifyImg(answer.author.image)(class='metadata-primary__image', alt='', aria-hidden='true')
p.metadata-primary__misc(aria-label=`${answer.author.name}'s credentials`)= answer.author.credential || '' p.metadata-primary__misc(aria-label=`${answer.author.name}'s credentials`)= answer.author.credential || ''
//- ORIGINAL QUESTION //- ORIGINAL QUESTION
h3.answer__question.heading.heading__tertiary if includeQuestion
span Originally answered to&nbsp; p.answer__question.heading.heading__tertiary
a.answer__link.answers__link(href=answer.originalQuestion.url) span Answered to&nbsp;
+spansChecker(answer.originalQuestion.text[0].spans) a.answer__link.answers__link(href=answer.question.url)
+spansChecker(answer.question.text[0].spans)
else if answer.originalQuestion
p.answer__question.heading.heading__tertiary
span Originally answered to&nbsp;
a.answer__link.answers__link(href=answer.originalQuestion.url)
+spansChecker(answer.originalQuestion.text[0].spans)
//- ANSWER //- ANSWER
section.answer__text.text__container section.answer__text.text__container

View file

@ -17,7 +17,7 @@ mixin spansChecker(spans)
a.text__span-link.text__link(href=span.modifiers.embed.url)= span.modifiers.embed.title || '(link to user embedded content)' a.text__span-link.text__link(href=span.modifiers.embed.url)= span.modifiers.embed.title || '(link to user embedded content)'
//- handle links //- handle links
- else if (span.modifiers.link) //- removing quora.com from the link in case it is a quora.com link. - else if (span.modifiers.link) //- removing quora.com from the link in case it is a quora.com link.
a.text__span-link.text__link(href=span.modifiers.link.url.split('https://www.quora.com')[1] || span.modifiers.link.url)=span.text +quetrefyUrl(span.modifiers.link.url, span.text)(class='text__span-link text__link')
//- handle bold + italic text //- handle bold + italic text
- else if (!!span.modifiers.bold && !!span.modifiers.italic) - else if (!!span.modifiers.bold && !!span.modifiers.italic)
strong.text__span-bold: em.text__span-italic= span.text strong.text__span-bold: em.text__span-italic= span.text
@ -42,7 +42,7 @@ mixin formatText(text)
//- handle images //- handle images
-if(para.type ==='image') -if(para.type ==='image')
.text__para.text__image-container .text__para.text__image-container
img.text__image(src=para.spans[0].modifiers.master_url.replace('https://', "/api/v1/image/"), alt='User embedded image', loading='lazy') +proxifyImg(para.spans[0].modifiers.master_url)(class='text__image', alt='User embedded image')
//- handle code blocks //- handle code blocks
- else if (para.type ==='code') - else if (para.type ==='code')
pre.text__para.text__code: code pre.text__para.text__code: code
@ -60,13 +60,13 @@ mixin formatText(text)
hr.text__para.text__hr hr.text__para.text__hr
//- handling embedded youtube video //- handling embedded youtube video
- else if(para.type === 'yt-embed') - else if(para.type === 'yt-embed')
.text__para.text__embed .text__para.text__embed
a.text__span-link.text__link(href=para.spans[0].modifiers.embed.url) (link to user-embedded YouTube video) a.text__span-link.text__link(href=para.spans[0].modifiers.embed.url) (link to user-embedded YouTube video)
//- handling embedded tweet //- handling embedded tweet
- else if(para.type === 'tweet') - else if(para.type === 'tweet')
.text__para.text__embed.text__embed-tweet .text__para.text__embed.text__embed-tweet
//- directly pasting raw HTML which pug will process just fine //- directly pasting raw HTML which pug will process just fine
| !{para.spans[0].modifiers.embed.html} | !{para.spans[0].modifiers.embed.html}
//- rest of the types that don't need/can't be put in a semantic html tag //- rest of the types that don't need/can't be put in a semantic html tag
- else - else
p(class=`text__para text__${para.type}`) p(class=`text__para text__${para.type}`)

View file

@ -1,8 +1,8 @@
//-////////////////////////////////////////////////////// //-//////////////////////////////////////////////////////
//- INCLUDES/EXTENDS //- INCLUDES/EXTENDS
//-////////////////////////////////////////////////////// //-//////////////////////////////////////////////////////
include ../mixins/_formatText include _formatText
include ../mixins/_utils include _utils
mixin addMetadataSecondary(iconName, name, number, numberType='number') mixin addMetadataSecondary(iconName, name, number, numberType='number')
p.metadata-secondary__item p.metadata-secondary__item

View file

@ -1,9 +1,9 @@
//-////////////////////////////////////////////////////// //-//////////////////////////////////////////////////////
//- INCLUDES/EXTENDS //- INCLUDES/EXTENDS
//-////////////////////////////////////////////////////// //-//////////////////////////////////////////////////////
include ../mixins/_formatText include _formatText
include ../mixins/_metadata include _metadata
include ../mixins/_utils include _utils
//-////////////////////////////////////////////////////// //-//////////////////////////////////////////////////////
//- MAIN CONTENT //- MAIN CONTENT
@ -12,14 +12,14 @@ mixin addPost(post)
article.answer article.answer
//- ABOUT AUTHOR //- ABOUT AUTHOR
if post.space if post.space
figure.answer__metdata-primary.metadata-primary .answer__metdata-primary.metadata-primary
figcaption.metadata-primary__heading p.metadata-primary__heading
a.answers__link(href=post.space.url)= post.space.name a.answers__link(href=post.space.url)= post.space.name
img.metadata-primary__image(src=post.space.image.replace('https://', "/api/v1/image/"), alt=`cover photo of ${post.space.name} space`, loading='lazy') +proxifyImg(post.space.image)(class='metadata-primary__image', alt='', aria-hidden='true')
p.metadata-primary__misc= post.space.description p.metadata-primary__misc= post.space.description
else else
figure.answer__metdata-primary.metadata-primary .answer__metdata-primary.metadata-primary
figcaption.metadata-primary__heading p.metadata-primary__heading
if post.author.isAnon if post.author.isAnon
span Anonymous span Anonymous
else else
@ -28,7 +28,7 @@ mixin addPost(post)
svg.metadata-primary__icon svg.metadata-primary__icon
title verified title verified
use(href='/misc/sprite.svg#icon-verified') use(href='/misc/sprite.svg#icon-verified')
img.metadata-primary__image(src=post.author.image.replace('https://', "/api/v1/image/"), alt=`${post.author.name}'s profile photo`, loading='lazy') +proxifyImg(post.author.image)(class='metadata-primary__image', alt='', aria-hidden='true')
p.metadata-primary__misc(aria-label=`${post.author.name}'s credentials`)= post.author.credential || '' p.metadata-primary__misc(aria-label=`${post.author.name}'s credentials`)= post.author.credential || ''
//- POST HEADING //- POST HEADING

View file

@ -1,9 +1,9 @@
//-////////////////////////////////////////////////////// //-//////////////////////////////////////////////////////
//- INCLUDES/EXTENDS //- INCLUDES/EXTENDS
//-////////////////////////////////////////////////////// //-//////////////////////////////////////////////////////
include ../mixins/_formatText include _formatText
include ../mixins/_metadata include _metadata
include ../mixins/_utils include _utils
//-////////////////////////////////////////////////////// //-//////////////////////////////////////////////////////
//- MAIN CONTENT //- MAIN CONTENT
@ -19,5 +19,6 @@ mixin addQuestion(question)
if question.lastFollowTime if question.lastFollowTime
+addMetadataSecondary('clock-edit', 'Last followed', question.lastFollowTime, 'date') +addMetadataSecondary('clock-edit', 'Last followed', question.lastFollowTime, 'date')
+addMetadataSecondary('users', 'Followers', question.numFollowers) +addMetadataSecondary('users', 'Followers', question.numFollowers)
+addMetadataSecondary('answers', 'Answers', question.numAnswers) if question.numAnswers
+addMetadataSecondary('answers', 'Answers', question.numAnswers)
+addMetadataSecondary('comments', 'Comments', question.numComments) +addMetadataSecondary('comments', 'Comments', question.numComments)

View file

@ -8,3 +8,38 @@ mixin addDate(date, className='')
mixin formatNumber(number, className='') mixin formatNumber(number, className='')
span(class=className)= new Intl.NumberFormat().format(number) span(class=className)= new Intl.NumberFormat().format(number)
mixin proxifyImg(imgUrl)
img(src=imgUrl.replace('https://', '/api/v1/image/'), loading='lazy')&attributes(attributes)
mixin quetrefyUrl(url, text)
-
const acceptedLanguages=['en','es','fr','de','it','ja','id','pt','hi','nl','da','fi','nb','sv','mr','bn','ta','ar','he','gu','kn','ml','te','po'];
let link;
const match = /^https:\/\/(.{2,})\.quora\.com(\/.*)$/.exec(url);
if (match) {
const [_, subdomain, rest] = match;
if (acceptedLanguages.includes(subdomain)) link = link = `${rest}${rest.includes('?') ? '&' : '?'}lang=${subdomain}`;
else if (subdomain === 'www') link = rest;
else link = `/space/${subdomain}${rest}`;
} else {
link = url;
}
a(href=link)&attributes(attributes)= text
mixin quorafyUrl(url, text='View on Quora')
-
const link = new URL(`${url.pathname}${url.search}`, 'https://www.quora.com');
const lang = link.searchParams.get('lang');
const match = /(?<=^\/space\/)([^\/]+)\/(.*)$/.exec(link.pathname); // taking space name and rest of pathname out.
if(lang) {
link.hostname = `${lang}.quora.com`;
link.searchParams.delete('lang');
} else if(match) {
link.hostname = `${match[1]}.quora.com`;
link.pathname = match[2];
}
a(href=link.href, rel='noreferrer', target='_blank')&attributes(attributes)= text

View file

@ -86,12 +86,6 @@ block content
summary.faqs__question There are some unreachable routes. summary.faqs__question There are some unreachable routes.
svg.faqs__icon: use(href='/misc/sprite.svg#icon-open') svg.faqs__icon: use(href='/misc/sprite.svg#icon-open')
p.faqs__answer I'm working to implement them soon. p.faqs__answer I'm working to implement them soon.
details.faqs__faq
summary.faqs__question Why is website connecting to 'cdn.jsdelivr.net'?
svg.faqs__icon: use(href='/misc/sprite.svg#icon-open')
p.faqs__answer It is for an open source library &ndash;&nbsp;
a.about__link(href='https://www.mathjax.org/') Mathjax
|&nbsp;&ndash; which is used to display math eqations nicely. If I get enough time, I'll include it locally.
details.faqs__faq details.faqs__faq
summary.faqs__question Why are some math equations showing up weirdly? summary.faqs__question Why are some math equations showing up weirdly?
svg.faqs__icon: use(href='/misc/sprite.svg#icon-open') svg.faqs__icon: use(href='/misc/sprite.svg#icon-open')

View file

@ -4,6 +4,7 @@
extends ../base extends ../base
include ../mixins/_formatText include ../mixins/_formatText
include ../mixins/_answer include ../mixins/_answer
include ../mixins/_utils
//-////////////////////////////////////////////////////// //-//////////////////////////////////////////////////////
//- MAIN CONTENT //- MAIN CONTENT
@ -18,7 +19,7 @@ block content
.answers__metadata .answers__metadata
p.answers__answers-total= `${ data.numAnswers ? 'Total answers: ' + data.numAnswers : 'Unanswered'}` p.answers__answers-total= `${ data.numAnswers ? 'Total answers: ' + data.numAnswers : 'Unanswered'}`
p.answers__answers-shown Viewable answers: #{data.answers.length} p.answers__answers-shown Viewable answers: #{data.answers.length}
a.answers__question-link.answers__link(href='https://www.quora.com' + data.question.url) View on Quora +quorafyUrl(meta.url)(class='answers__question-link answers__link')
//- ANSWERS TO THIS QUESTION //- ANSWERS TO THIS QUESTION
.answers-box.answers__answers-box .answers-box.answers__answers-box

View file

@ -2,6 +2,7 @@
//- INCLUDES/EXTENDS //- INCLUDES/EXTENDS
//-////////////////////////////////////////////////////// //-//////////////////////////////////////////////////////
extends ../base extends ../base
include ../mixins/_utils
//-////////////////////////////////////////////////////// //-//////////////////////////////////////////////////////
//- MAIN CONTENT //- MAIN CONTENT
@ -22,5 +23,5 @@ block content
a.error__link(href="/") Home Page a.error__link(href="/") Home Page
|. |.
p.error__return Or view this route&nbsp; p.error__return Or view this route&nbsp;
a.error__link(href="https://www.quora.com" + meta.urlObj.pathname) on Quora +quorafyUrl(meta.url, 'on Quora')(class='error__link')
|. |.

View file

@ -31,7 +31,3 @@ block content
summary.faqs__question Data stored locally in your browser summary.faqs__question Data stored locally in your browser
svg.faqs__icon: use(href='/misc/sprite.svg#icon-open') svg.faqs__icon: use(href='/misc/sprite.svg#icon-open')
p.faqs__answer A key called 'theme' is stored in local storage provided by your browser to store your theme preference should you override the default theme. To prevent this behaviour, either disable JavaScript or local storage for Quetre. p.faqs__answer A key called 'theme' is stored in local storage provided by your browser to store your theme preference should you override the default theme. To prevent this behaviour, either disable JavaScript or local storage for Quetre.
details.faqs__faq
summary.faqs__question Data collected by other services
svg.faqs__icon: use(href='/misc/sprite.svg#icon-open')
p.faqs__answer If you're using the official instance (which is deployed on Heroku), Heroku might log your IP to prevent abuse. Also, as Quetre connects to 'cdn.jsdelivr.net' for the mathjax library, this service might log some data. So, follow due precaution. Using a VPN might be a good idea. Or even better, consider hosting your own instance.

View file

@ -36,7 +36,7 @@ block content
section.profile__meta.profile-meta section.profile__meta.profile-meta
.profile-meta__basic .profile-meta__basic
.metadata-primary.profile-meta__about .metadata-primary.profile-meta__about
img.metadata-primary__image.profile-meta__image(src=data.basic.image.replace('https://', "/api/v1/image/"), alt=`${data.basic.name}'s profile photo`, loading='lazy') +proxifyImg(data.basic.image)(class='metadata-primary__image profile-meta__image', alt='', aria-hidden='true')
h1.heading.heading__primary.metadata-primary__heading.profile-meta__heading-name.profile__name= data.basic.name h1.heading.heading__primary.metadata-primary__heading.profile-meta__heading-name.profile__name= data.basic.name
if data.basic.isVerified if data.basic.isVerified
svg.icon.metadata-primary__icon.profile-meta__icon svg.icon.metadata-primary__icon.profile-meta__icon
@ -60,7 +60,7 @@ block content
+addMetadataSecondary('flower', 'Deceased', '', 'empty') +addMetadataSecondary('flower', 'Deceased', '', 'empty')
if data.basic.isBusiness if data.basic.isBusiness
+addMetadataSecondary('briefcase', 'Business', '', 'empty') +addMetadataSecondary('briefcase', 'Business', '', 'empty')
a.link(href='https://www.quora.com' + data.basic.profile) View on Quora +quorafyUrl(meta.url)(class='link')
if data.profileFeed.description[0].spans[0].text if data.profileFeed.description[0].spans[0].text
.profile-meta__description .profile-meta__description
@ -150,7 +150,7 @@ block content
ul.profile-spaces__list ul.profile-spaces__list
each space in data.spaces.spaces each space in data.spaces.spaces
li.metadata-primary.profile-spaces__list-item li.metadata-primary.profile-spaces__list-item
img.metadata-primary__image(src=space.image.replace('https://', '/api/v1/image/'), alt=`dedicated photo of ${space.name} space`) +proxifyImg(space.image)(class='metadata-primary__image', alt='', aria-hidden='true')
a.link.metadata-primary__heading(href=space.url)= space.name a.link.metadata-primary__heading(href=space.url)= space.name
p.metadata-primary__misc p.metadata-primary__misc
+formatNumber(space.numItems) +formatNumber(space.numItems)
@ -166,7 +166,7 @@ block content
ul.profile-topics__list ul.profile-topics__list
each topic in data.topics.topics each topic in data.topics.topics
li.metadata-primary.profile-topics__list-item li.metadata-primary.profile-topics__list-item
img.metadata-primary__image.profile-topics__img(src=topic.image.replace('https://', '/api/v1/image/'), alt=`image depicting ${topic.name} topic`) +proxifyImg(topic.image)(class='metadata-primary__image profile-topics__img', alt='', aria-hidden='true')
a.link.metadata-primary__heading(href=topic.url)= topic.name a.link.metadata-primary__heading(href=topic.url)= topic.name
p.metadata-primary__misc p.metadata-primary__misc
+formatNumber(topic.numAnswers) +formatNumber(topic.numAnswers)
@ -185,7 +185,7 @@ block content
//- svg.icon.profile__icon: use(href='/misc/sprite.svg#icon-verified') //- svg.icon.profile__icon: use(href='/misc/sprite.svg#icon-verified')
//- span pinned //- span pinned
- if (item.type === 'answer') - if (item.type === 'answer')
+addAnswer(item) +addAnswer(item, true)
- else if (item.type === 'question') - else if (item.type === 'question')
+addQuestion(item) +addQuestion(item)
- else if (item.type === 'post') - else if (item.type === 'post')

View file

@ -0,0 +1,89 @@
//-//////////////////////////////////////////////////////
//- INCLUDES/EXTENDS
//-//////////////////////////////////////////////////////
extends ../base
include ../mixins/_formatText
include ../mixins/_utils
include ../mixins/_answer
include ../mixins/_question
include ../mixins/_post
include ../mixins/_metadata
//-//////////////////////////////////////////////////////
//- MAIN CONTENT
//-//////////////////////////////////////////////////////
block content
main#main(class=`main search ${data ? '' :'search--no-results'}`)
-
const typesArr = [{key: 'question', text: 'Questions'}, {key: 'answer', text: 'Answers'}, {key: 'post', text: 'Posts'}, {key: 'profile', text: 'Profiles'}, {key: 'topic', text: 'Topics'}, {key: 'tribe', text: 'Spaces'}]
const timesArr = [{key: 'hour', text: 'Hour'}, {key: 'day', text: 'Day'}, {key: 'week', text: 'Week'}, {key: 'month', text: 'Month'}, {key: 'year', text: 'Year'}]
const languagesArr = ['en','es','fr','de','it','ja','id','pt','hi','nl','da','fi','nb','sv','mr','bn','ta','ar','he','gu','kn','ml','te','po'];
form.search__form.search-form(action="/search", method='get', autocomplete='off', name='search')
.search-form__search-container
input.search-form__searchbar(type="search", name="q", placeholder='Enter your query...', minlength='3', aria-label='search for anything')
button.search-form__button.search-form__button--reset(type="reset", aria-label='clear searchbar and filters'): svg.icon: use(href='/misc/sprite.svg#icon-cross')
button.search-form__button.search-form__button--submit(type="submit", aria-label='search'): svg.icon: use(href='/misc/sprite.svg#icon-search')
.search-form__filters-container.search-form__filters-container--type
p.search-form__filters-heading Filter by Type
each item in typesArr
.search-form__filters-group
input.search-form__radio.visually-hidden(type="radio", name="type", value=item.key, id=`type--${item.key}`)
label.search-form__label(for=`type--${item.key}`)= item.text
.search-form__filters-container.search-form__filters-container--time
p.search-form__filters-heading Filter by Time
each item in timesArr
.search-form__filters-group
input.search-form__radio.visually-hidden(type="radio", name='time', value=item.key, id=`time--${item.key}`)
label.search-form__label(for=`time--${item.key}`)= item.text
.search-form__filters-container.search-form__filters-container--lang
p.search-form__filters-heading Filter by Language
each lang in languagesArr
.search-form__filters-group
input.search-form__radio.visually-hidden(type="radio", name='lang', value=lang, id=`lang--${lang}`)
label.search-form__label(for=`lang--${lang}`)= lang.toUpperCase()
//- TODO: refactor 'profile', 'topic', and 'space' into resusable mixins.
- if (data?.results)
section.search__results.search-results
h1.heading.heading__primary Results
- if (data.results.length === 0)
p No results found for the query. Try being less specific and/or removing filters.
- else
.search-results__container
each item in data.results
.search-results__item
- if (item.type === 'answer')
+addAnswer(item, true)
- else if (item.type === 'question')
+addQuestion(item)
- else if (item.type === 'post')
+addPost(item)
- else if (item.type === 'profile')
.metadata-primary
p.metadata-primary__heading
if item.isAnon
span Anonymous
else
a.link.metadata-primary__link(href=item.url)= item.name
if item.isVerified
svg.icon.metadata-primary__icon
title verified
use(href='/misc/sprite.svg#icon-verified')
+proxifyImg(item.image)(class='metadata-primary__image', alt='', aria-hidden='true')
p.metadata-primary__misc(aria-label=`${item.name}'s credentials`)= item.credential || ''
- else if (item.type === 'topic')
.metadata-primary
a.link.metadata-primary__heading(href=item.url)= item.name
+proxifyImg(item.image)(class='metadata-primary__image', aria-hidden='true', alt='')
p.metadata-primary__misc
+formatNumber(item.numFollowers)
|&nbsp;Followers
- else if (item.type === 'space')
.metadata-primary.profile-spaces__list-item
+proxifyImg(item.image)(class='metadata-primary__image', alt='', aria-hidden='true')
a.link.metadata-primary__heading(href=item.url)= item.name
p.metadata-primary__misc= item.description

View file

@ -14,7 +14,7 @@ block content
section.topic__stats.topic-stats section.topic__stats.topic-stats
.metadata-primary .metadata-primary
h1.heading.heading__primary.metadata-primary__heading.topic__name= data.name h1.heading.heading__primary.metadata-primary__heading.topic__name= data.name
img.metadata-primary__image(src=data.image.replace('https://', "/api/v1/image/") alt=`cover photo of '${data.name}' topic`, loading='lazy') +proxifyImg(data.image)(class='metadata-primary__image', alt='', aria-hidden='true')
p.metadata-primary__misc p.metadata-primary__misc
if data.aliases.length if data.aliases.length
span Also known as:&nbsp; span Also known as:&nbsp;
@ -24,7 +24,7 @@ block content
+addMetadataSecondary('question','Questions', data.numQuestions) +addMetadataSecondary('question','Questions', data.numQuestions)
if data.isAdult if data.isAdult
+addMetadataSecondary('danger', 'Adult Topic', '18+', true) +addMetadataSecondary('danger', 'Adult Topic', '18+', true)
a.link(href='https://www.quora.com' + data.url) View on Quora +quorafyUrl(meta.url)(class='link')
//- AUTHORS RELATED TO THE TOPIC AND METADATA //- AUTHORS RELATED TO THE TOPIC AND METADATA
.topic__famous-authors.famous-authors .topic__famous-authors.famous-authors
@ -32,8 +32,8 @@ block content
.famous-authors__list .famous-authors__list
each author in data.mostViewedAuthors each author in data.mostViewedAuthors
.famous-authors__item .famous-authors__item
figure.metadata-primary .metadata-primary
figcaption.metadata-primary__heading p.metadata-primary__heading
if author.isAnon if author.isAnon
span Anonymous span Anonymous
else else
@ -42,7 +42,7 @@ block content
svg.icon.metadata-primary__icon svg.icon.metadata-primary__icon
title verified title verified
use(href='/misc/sprite.svg#icon-verified') use(href='/misc/sprite.svg#icon-verified')
img.metadata-primary__image(src=author.image.replace('https://', "/api/v1/image/"), alt=`${author.name}'s profile photo`, loading='lazy') +proxifyImg(author.image)(class='metadata-primary__image', alt='', aria-hidden='true')
p.metadata-primary__misc(aria-label=`${author.name}'s credentials`)= author.credential || '' p.metadata-primary__misc(aria-label=`${author.name}'s credentials`)= author.credential || ''
.metadata-secondary .metadata-secondary
+addMetadataSecondary('user', 'Followers', author.numFollowers) +addMetadataSecondary('user', 'Followers', author.numFollowers)
@ -56,7 +56,7 @@ block content
each topic in data.relatedTopics each topic in data.relatedTopics
.metadata-primary .metadata-primary
a.link.metadata-primary__heading(href=topic.url)= topic.name a.link.metadata-primary__heading(href=topic.url)= topic.name
img.metadata-primary__image(src=topic.image.replace('https://', "/api/v1/image/") alt=`cover photo of '${topic.name}' topic`, loading='lazy') +proxifyImg(topic.image)(class='metadata-primary__image', aria-hidden='true', alt='')
p.metadata-primary__misc p.metadata-primary__misc
+formatNumber(topic.numFollowers) +formatNumber(topic.numFollowers)
|&nbsp;Followers |&nbsp;Followers

View file

@ -10,16 +10,22 @@
$clr-primary: #e3f6f5; $clr-primary: #e3f6f5;
$clr-secondary: #272343; $clr-secondary: #272343;
$clr-highlight: #ff5277; $clr-highlight: #ff5277;
$ff-default: -apple-system, BlinkMacSystemFont, avenir next, avenir, segoe ui,
helvetica neue, Cantarell, Ubuntu, roboto, noto, arial, sans-serif;
$ff-monospace: Menlo, Consolas, Monaco, Liberation Mono, Lucida Console,
monospace;
$misc-vars: ( $misc-vars: (
ff-primary: ( ff-primary: (
'Worksans', 'Worksans',
sans-serif, $ff-default,
), ),
ff-alt-alpha: ( ff-alt-alpha: (
'QuickSand', 'QuickSand',
sans-serif, $ff-default,
), ),
ff-default: $ff-default,
ff-mono: $ff-monospace,
fs-160: 1.6rem, fs-160: 1.6rem,
fs-180: 1.8rem, fs-180: 1.8rem,
fs-200: 2rem, fs-200: 2rem,
@ -45,6 +51,10 @@ $misc-vars: (
); );
$themed-vars: ( $themed-vars: (
color-scheme: (
light: 'light',
dark: 'dark',
),
// base // base
clr-base-bg: clr-base-bg:
( (
@ -152,5 +162,5 @@ $themed-vars: (
clr-focus: ( clr-focus: (
light: color.scale($clr-highlight, $lightness: 0%, $saturation: 0%), light: color.scale($clr-highlight, $lightness: 0%, $saturation: 0%),
dark: color.scale($clr-highlight), dark: color.scale($clr-highlight),
) ),
); );

View file

@ -43,6 +43,8 @@ body {
// BASE STYLING // BASE STYLING
//////////////////////////////////////////////////////// ////////////////////////////////////////////////////////
:root { :root {
color-scheme: light dark;
// normal vars // normal vars
@include get-misc-vars; @include get-misc-vars;
// themed vars(default:light) // themed vars(default:light)
@ -61,9 +63,16 @@ body {
@include get-themed-vars(dark); @include get-themed-vars(dark);
} }
} }
// disabling fonts for webkit
@include fix-for-safari {
--ff-primary: var(--ff-default);
--ff-alt-alpha: var(--ff-default);
}
} }
.body { .body {
color-scheme: var(--color-scheme);
font-size: var(--fs-160); font-size: var(--fs-160);
font-family: var(--ff-primary); font-family: var(--ff-primary);
background-color: var(--clr-base-bg); background-color: var(--clr-base-bg);
@ -126,8 +135,7 @@ body {
// KEYBOARD NAVIGATION // KEYBOARD NAVIGATION
//////////////////////////////////////////////////////// ////////////////////////////////////////////////////////
:focus { :focus {
outline: 3px solid var(--clr-focus); @include focus-rules;
outline-offset: 0.2em;
} }
@supports selector(:focus-visible) { @supports selector(:focus-visible) {
@ -136,30 +144,6 @@ body {
} }
:focus-visible { :focus-visible {
outline: 3px solid var(--clr-focus); @include focus-rules;
outline-offset: 0.2em;
} }
} }
////////////////////////////////////////////////////////
// FOR MATHJAX
////////////////////////////////////////////////////////
mjx-container.MathJax {
pointer-events: none;
}
/*
.CtxtMenu_ContextMenu,
.CtxtMenu_Info {
background-color: var(--clr-base-bg) !important;
font-family: inherit !important;
color: inherit !important;
}
.CtxtMenu_InfoTitle,
.CtxtMenu_InfoSignature,
.CtxtMenu_MenuClose .CtxtMenu_InfoClose,
.CtxtMenu_InfoContent {
font-family: inherit !important;
background: inherit !important;
color: inherit !important;
}
*/

View file

@ -51,6 +51,8 @@
.icon { .icon {
max-height: var(--fs-500); max-height: var(--fs-500);
max-width: var(--fs-500); max-width: var(--fs-500);
height: 100%;
width: 100%;
fill: var(--clr-base-icon); fill: var(--clr-base-icon);
&__down { &__down {
@ -81,6 +83,23 @@
} }
} }
////////////////////////////////////////////////////////
// HELPER CLASSES
////////////////////////////////////////////////////////
.visually-hidden {
clip: rect(1px, 1px, 1px, 1px) !important;
border: 0 !important;
-webkit-clip-path: inset(50%) !important;
clip-path: inset(50%) !important;
height: 1px !important;
margin: -1px !important;
overflow: hidden !important;
padding: 0 !important;
position: absolute !important;
white-space: nowrap !important;
width: 1px !important;
}
//////////////////////////////////////////////////////// ////////////////////////////////////////////////////////
// LINKS // LINKS
//////////////////////////////////////////////////////// ////////////////////////////////////////////////////////
@ -283,6 +302,11 @@
list-style-type: none; list-style-type: none;
cursor: pointer; cursor: pointer;
// for hinding triangle on webkit
&::-webkit-details-marker {
display: none;
}
} }
&__icon { &__icon {
height: 1em; height: 1em;
@ -507,3 +531,119 @@
gap: var(--space-800); gap: var(--space-800);
} }
} }
////////////////////////////////////////////////////////
// SEARCH PAGE COMPONENTS
////////////////////////////////////////////////////////
///
.search-form {
display: grid;
gap: var(--space-200);
// justify-items: center;
&__search-container {
display: grid;
grid-auto-flow: column;
grid-auto-columns: minmax(15rem, auto) 3rem 3rem;
grid-auto-rows: 3rem;
border: 1px solid var(--clr-base-icon-alt-alpha);
border-radius: 100vmax;
overflow: hidden;
padding: var(--space-100);
gap: var(--space-050);
&:focus-within {
background-color: var(--clr-code-bg);
color: var(--clr-code-text);
}
}
&__searchbar {
outline: none;
border: none;
background-color: transparent;
font: inherit;
caret-color: var(--clr-base-icon);
// fix for browsers with non-standard properties. yes, webkit and blink suck.
-webkit-appearance: none;
&::-webkit-search-cancel-button {
display: none;
}
}
&__button {
border: none;
background: transparent;
cursor: pointer;
border-radius: 100vmax;
&:hover {
transform: scale(1.1);
}
&--reset {
}
&--submit {
}
}
&__filters-container {
display: flex;
flex-wrap: wrap;
gap: 2rem 1rem;
}
&__filters-heading {
flex-basis: 100%;
}
&__filters-group {
position: relative;
}
&__radio {
}
&__label {
cursor: pointer;
background: var(--clr-code-bg);
color: var(--clr-code-text);
padding: 1rem;
border-radius: 1rem;
}
&__radio:checked + &__label {
color: var(--clr-selection-text);
background: var(--clr-selection-bg);
}
&__radio:focus + &__label {
@include focus-rules;
}
@supports selector(:focus-visible) {
&__radio:focus + &__label {
outline: none;
}
&__radio:focus-visible + &__label {
@include focus-rules;
}
}
}
.search-results {
--img-dim: var(--fs-600);
display: grid;
gap: var(--space-500);
&__container {
display: grid;
gap: var(--space-500);
}
&__item {
}
}

View file

@ -76,7 +76,13 @@
} }
} }
&__theme { &__misc {
display: flex;
gap: var(--space-200);
}
&__theme,
&__search {
height: var(--fs-300); height: var(--fs-300);
width: var(--fs-300); width: var(--fs-300);
@ -86,6 +92,12 @@
} }
} }
// search icon viewBox is larger than content, hence the fix
&__search > svg {
height: 105%;
width: 105%;
}
&__info { &__info {
display: grid; display: grid;
place-items: center; place-items: center;

View file

@ -63,6 +63,11 @@
} }
} }
@mixin focus-rules {
outline: 3px solid var(--clr-focus);
outline-offset: 0.2em;
}
//////////////////////////////////////////////////////// ////////////////////////////////////////////////////////
// BREAKPOINT MANAGER // BREAKPOINT MANAGER
//////////////////////////////////////////////////////// ////////////////////////////////////////////////////////
@ -104,3 +109,13 @@ $breakpoints: (
@content; @content;
} }
} }
////////////////////////////////////////////////////////////////
// CHECK IF BROWSER IS SAFARI(it's the new IE)
////////////////////////////////////////////////////////////////
@mixin fix-for-safari {
@supports (-webkit-appearance: none) and (stroke-color: transparent) {
@content;
}
}

View file

@ -405,3 +405,54 @@
padding-inline: var(--space-200); padding-inline: var(--space-200);
} }
} }
////////////////////////////////////////////////////////
// SEARCH
////////////////////////////////////////////////////////
.search {
// justify-self: center;
padding: var(--space-500) var(--space-800);
display: grid;
grid-template-columns: 2fr 1.2fr;
gap: var(--space-500);
align-items: start;
&--no-results {
grid-template-columns: unset;
place-content: center;
max-width: 100rem;
margin-inline: auto;
}
&__results {
grid-column: 1 / 2;
grid-row: 1 / 2;
}
&__form {
grid-row: 1 / 2;
grid-column: -2 / -1;
}
@include respond-to(bp-1200) {
padding: var(--space-500);
gap: var(--space-500);
}
@include respond-to(bp-900) {
grid-template-columns: auto;
grid-template-rows: max-content auto;
&__results {
grid-row: 2 / 3;
}
&__form {
grid-row: 1 / 2;
}
}
@include respond-to(bp-550) {
padding-inline: var(--space-200);
}
}