├── .npmignore ├── .gitignore ├── src ├── libs │ ├── axios.ts │ └── interactions.ts ├── api │ ├── voting.ts │ ├── oauth.ts │ ├── answers.ts │ ├── publish.ts │ ├── posts.ts │ ├── search.ts │ ├── tags.ts │ ├── users.ts │ ├── comments.ts │ ├── questions.ts │ ├── series.ts │ └── me.ts ├── sdk.ts ├── echo │ ├── NewPostsChannel.ts │ ├── events.ts │ ├── PrivateChannel.ts │ └── index.ts ├── auth.ts ├── markdown │ ├── plugins │ │ ├── linkify-mention.ts │ │ ├── katex │ │ │ ├── katex.ts │ │ │ ├── math_inline.ts │ │ │ └── math_block.ts │ │ ├── embed.ts │ │ ├── clipboard.ts │ │ └── highlight.ts │ ├── utils.ts │ └── index.ts └── types │ ├── laravel-echo.d.ts │ ├── api.d.ts │ └── markdown-it.d.ts ├── .travis.yml ├── tslint.json ├── scripts └── copy-files.js ├── tsconfig.json ├── package.json ├── themes ├── clipboard-code.scss └── prism-github.scss └── README.md /.npmignore: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | docs 4 | /.idea 5 | /*.log 6 | yarn.lock 7 | -------------------------------------------------------------------------------- /src/libs/axios.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | 3 | export default axios.create({ 4 | baseURL: 'https://api.viblo.asia' 5 | }); 6 | -------------------------------------------------------------------------------- /src/api/voting.ts: -------------------------------------------------------------------------------- 1 | import axios from '../libs/axios'; 2 | import { RateableType, VoteDir } from '../libs/interactions'; 3 | 4 | export const castVote = (type: RateableType, hashId: string, score: VoteDir) => 5 | axios.post(`/${type}/${hashId}/rate`, { score }); 6 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | 3 | node_js: 4 | - "stable" 5 | 6 | branches: 7 | only: 8 | - master 9 | 10 | script: 11 | - yarn lint 12 | - yarn build 13 | 14 | notifications: 15 | email: 16 | on_success: never 17 | on_failure: change 18 | 19 | cache: 20 | yarn: true 21 | directories: 22 | - node_modules 23 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "tslint-config-airbnb" 4 | ], 5 | "rules": { 6 | "ter-indent": 4, 7 | "max-line-length": [true, 120], 8 | "trailing-comma": [true, "never"], 9 | "import-name": false, 10 | "variable-name": [true, "check-format", "allow-leading-underscore"], 11 | "align": [true, "parameters"] 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /scripts/copy-files.js: -------------------------------------------------------------------------------- 1 | const fse = require('fs-extra'); 2 | 3 | fse.copy('src/types', 'dist/types'); 4 | 5 | const files = ['package.json', 'yarn.lock', 'README.md', 'themes']; 6 | 7 | files.forEach(file => 8 | fse.copy(file, `dist/${file}`) 9 | .catch(() => { 10 | console.log(`Cannot copy ${file}`) 11 | }) 12 | ); 13 | -------------------------------------------------------------------------------- /src/api/oauth.ts: -------------------------------------------------------------------------------- 1 | import axios from '../libs/axios'; 2 | 3 | export const getApiKeys = () => axios.get('/oauth/personal-access-tokens').then(_ => _.data); 4 | 5 | export const createApiKey = (name: string) => axios.post('/oauth/personal-access-tokens', { name }).then(_ => _.data); 6 | 7 | export const revokeApiKey = (tokenId: string, password: string) => 8 | axios.post(`/oauth/tokens/${tokenId}/revoke`, { password }); 9 | -------------------------------------------------------------------------------- /src/api/answers.ts: -------------------------------------------------------------------------------- 1 | import axios from '../libs/axios'; 2 | 3 | export const getAnswer = (answer: string) => 4 | axios.get(`/answers/${answer}`); 5 | 6 | export const postAnswer = (question: string, values: object) => 7 | axios.post(`/questions/${question}/answers`, values); 8 | 9 | export const updateAnswer = (hashId: string, values: object) => 10 | axios.put(`/answers/${hashId}`, values); 11 | 12 | export const deleteAnswer = (hashId: string) => axios.delete(`/answers/${hashId}`); 13 | -------------------------------------------------------------------------------- /src/api/publish.ts: -------------------------------------------------------------------------------- 1 | import axios from '../libs/axios'; 2 | 3 | interface PostEdit { 4 | 5 | } 6 | 7 | export const getPostForEdit = (hashId: string): Promise => 8 | axios.get(`/posts/${hashId}/edit`).then(_ => _.data); 9 | 10 | export const savePostRevision = (input: object) => 11 | axios.post('/publish/post/autosave', input); 12 | 13 | export const saveAsDraft = savePostRevision; 14 | 15 | export const saveAndPublish = (input: object) => 16 | axios.post('/publish/post', input); 17 | -------------------------------------------------------------------------------- /src/sdk.ts: -------------------------------------------------------------------------------- 1 | interface OAuthConfig { 2 | client_id: string; 3 | client_secret: string; 4 | } 5 | 6 | interface ConfigOption { 7 | oauth?: OAuthConfig; 8 | } 9 | 10 | export class Config { 11 | oauth?: OAuthConfig; 12 | 13 | constructor (options: ConfigOption) { 14 | this.oauth = options.oauth; 15 | } 16 | } 17 | 18 | export let config: Config; 19 | 20 | export function init(options: ConfigOption) { 21 | config = new Config(options); 22 | } 23 | 24 | export { default as axios } from './libs/axios'; 25 | -------------------------------------------------------------------------------- /src/echo/NewPostsChannel.ts: -------------------------------------------------------------------------------- 1 | import Echo = require('laravel-echo'); 2 | import { NewPostPublishedEvent } from './events'; 3 | import { Channel } from 'laravel-echo/src/channel'; 4 | 5 | export default class NewPostsChannel 6 | { 7 | private channel: Channel; 8 | 9 | constructor (connection: Echo) { 10 | this.channel = connection.channel('newly-published-post'); 11 | } 12 | 13 | public onNewPostPublished(listener: (event: NewPostPublishedEvent) => void) { 14 | this.channel.listen('Posts\\Published', listener); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/echo/events.ts: -------------------------------------------------------------------------------- 1 | export interface NewNotificationEvent { 2 | id: string; 3 | type: string; 4 | notification: { 5 | type: string; 6 | data: object; 7 | sender?: object; 8 | }; 9 | } 10 | 11 | export interface NotificationClearedEvent { 12 | /** IDs of cleared notifications */ 13 | ids: number[]; 14 | /** Whether the notifications are mark as read or deleted permanently */ 15 | deleted: Boolean; 16 | } 17 | 18 | export interface NewPostPublishedEvent { 19 | user: object; 20 | post: object; 21 | } 22 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "moduleResolution": "node", 4 | "target": "es5", 5 | "module": "commonjs", 6 | "lib": ["es2015", "es2016", "es2017", "dom"], 7 | "strict": true, 8 | "declaration": true, 9 | "allowSyntheticDefaultImports": true, 10 | "experimentalDecorators": true, 11 | "emitDecoratorMetadata": true, 12 | "noImplicitAny": false, 13 | "strictPropertyInitialization": false, 14 | "outDir": "dist" 15 | }, 16 | "include": [ 17 | "src" 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /src/auth.ts: -------------------------------------------------------------------------------- 1 | import axios from './libs/axios'; 2 | 3 | export interface OAuthToken { 4 | token_type: string; 5 | access_token: string; 6 | } 7 | 8 | let currentToken: OAuthToken | null = null; 9 | 10 | /** 11 | * Set current token and set the Authorization header for all requests. 12 | */ 13 | export function setAccessToken(token: OAuthToken) { 14 | currentToken = token; 15 | axios.defaults.headers.common['Authorization'] = `${token.token_type} ${token.access_token}`; 16 | } 17 | 18 | /** 19 | * Get the current token. 20 | */ 21 | export const getCurrentToken = (): OAuthToken | null => currentToken ? ({ ...currentToken }) : null; 22 | -------------------------------------------------------------------------------- /src/libs/interactions.ts: -------------------------------------------------------------------------------- 1 | export enum VoteDir { 2 | Up = 'up', 3 | Down = 'down', 4 | None = 'none' 5 | } 6 | 7 | export enum RateableType { 8 | Post = 'posts', 9 | Series = 'series', 10 | Question = 'questions', 11 | Answer = 'answers', 12 | Comment = 'comments' 13 | } 14 | 15 | export enum SubscribableType { 16 | Tag = 'tag', 17 | User = 'user', 18 | Post = 'post', 19 | Series = 'series', 20 | Question = 'question' 21 | } 22 | 23 | export enum CommentableType { 24 | Post = 'posts', 25 | Series = 'series', 26 | Question = 'questions', 27 | Answer = 'answers', 28 | User = 'users' 29 | } 30 | -------------------------------------------------------------------------------- /src/api/posts.ts: -------------------------------------------------------------------------------- 1 | import axios from '../libs/axios'; 2 | import { 3 | Post, 4 | PostFull, 5 | Request, 6 | PagedResource 7 | } from '../types/api'; 8 | 9 | export enum PostFeedType { 10 | Newest = 'newest', 11 | Trending = 'trending', 12 | Following = 'followings', 13 | Clipped = 'clips', 14 | Featured = 'editors-choice' 15 | } 16 | 17 | export const getPostsFeed = (feed: PostFeedType, params?: Request): Promise> => 18 | axios.get(`/posts/${feed}`, { params }).then(_ => _.data); 19 | 20 | export const getPost = (hashId: string): Promise => axios.get(`/posts/${hashId}`).then(_ => _.data.post); 21 | export const deletePost = (hashId: string) => axios.delete(`/posts/${hashId}`); 22 | -------------------------------------------------------------------------------- /src/markdown/plugins/linkify-mention.ts: -------------------------------------------------------------------------------- 1 | export const createDefinition = (baseURL: string) => ({ 2 | validate (text: string, pos: number, self: any) { 3 | const tail = text.slice(pos); 4 | 5 | if (!self.re.mention) { 6 | self.re.mention = /^([\w_.\\-]{3,255})\b/; 7 | } 8 | 9 | if (self.re.mention.test(tail)) { 10 | if (pos >= 2 && tail[pos - 2] === '@') { 11 | return false; 12 | } 13 | 14 | // @ts-ignore 15 | return tail.match(self.re.mention)[0].length; 16 | } 17 | 18 | return 0; 19 | }, 20 | 21 | normalize (match) { 22 | const username = match.url.replace(/^@/, ''); 23 | match.url = `${baseURL}/${username}`; 24 | } 25 | }); 26 | -------------------------------------------------------------------------------- /src/echo/PrivateChannel.ts: -------------------------------------------------------------------------------- 1 | import Echo = require('laravel-echo'); 2 | import { Channel } from 'laravel-echo/src/channel'; 3 | import { NewNotificationEvent, NotificationClearedEvent } from './events'; 4 | 5 | export default class PrivateChannel 6 | { 7 | private channel: Channel; 8 | 9 | constructor (userId: number, connection: Echo) { 10 | const channel = `Framgia.Viblo.Models.User.${userId}`; 11 | this.channel = connection.private(channel); 12 | } 13 | 14 | public onNewNotification(listener: (event: NewNotificationEvent) => void) { 15 | this.channel.notification(listener); 16 | } 17 | 18 | public onNotificationCleared(listener: (event: NotificationClearedEvent) => void) { 19 | this.channel.listen('NotificationsCleared', listener); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/api/search.ts: -------------------------------------------------------------------------------- 1 | import axios from '../libs/axios'; 2 | import { PagedResource } from '../types/api'; 3 | 4 | export enum SearchType { 5 | Post = 'posts', 6 | Question = 'questions' 7 | } 8 | 9 | interface SearchRequest { 10 | q: string; 11 | s: string; 12 | o: string; 13 | } 14 | 15 | interface SearchResult { 16 | highlights: { 17 | title: string[]; 18 | contents: string[]; 19 | code: string[]; 20 | }; 21 | } 22 | 23 | export const search = (type: SearchType, params: SearchRequest): Promise> => 24 | axios.get(`/search/${type}`, { params }).then(_ => _.data); 25 | 26 | export const multisearch = (searchQuery: string, params?: Object) => axios.get('/search/multi', { 27 | params: { 28 | q: searchQuery, 29 | ...params 30 | } 31 | }).then(_ => _.data); 32 | -------------------------------------------------------------------------------- /src/api/tags.ts: -------------------------------------------------------------------------------- 1 | import axios from '../libs/axios'; 2 | import { 3 | Post, 4 | Series, 5 | Question, 6 | UserItem, 7 | TagItem, 8 | Request, 9 | PagedResource 10 | } from '../types/api'; 11 | 12 | export const getTags = (params?: Request): Promise> => 13 | axios.get('tags', { params }).then(_ => _.data); 14 | 15 | export const getTagInfo = (tag): Promise => axios.get(`/tags/${tag}`).then(_ => _.data); 16 | 17 | const associatedResource = (type: string) => 18 | (tag: string, params?: Request): Promise> => 19 | axios.get(`/tags/${tag}/${type}`, { params }).then(_ => _.data); 20 | 21 | export const getTagPosts = associatedResource('posts'); 22 | export const getTagQuestions = associatedResource('questions'); 23 | export const getTagSeries = associatedResource('series'); 24 | export const getTagFollowers = associatedResource('followers'); 25 | -------------------------------------------------------------------------------- /src/types/laravel-echo.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'laravel-echo' { 2 | import { Channel, PresenceChannel } from 'laravel-echo/src/channel'; 3 | 4 | class Echo { 5 | /** 6 | * Create an Echo instance. 7 | */ 8 | constructor (options: any); 9 | 10 | /** 11 | * Listen for an event on a channel instance. 12 | */ 13 | listen (channel: string, event: string, callback: Function): void; 14 | 15 | /** 16 | * Get a channel instance by name. 17 | */ 18 | channel (channel: string): Channel; 19 | 20 | /** 21 | * Get a private channel instance by name. 22 | */ 23 | private (channel: string): Channel; 24 | 25 | /** 26 | * Get a presence channel instance by name. 27 | */ 28 | join (channel: string): PresenceChannel; 29 | 30 | /** 31 | * Leave the given channel. 32 | */ 33 | leave (channel: string): void; 34 | 35 | /** 36 | * Disconnect from the Echo server. 37 | */ 38 | disconnect (): void; 39 | } 40 | 41 | export = Echo; 42 | } 43 | -------------------------------------------------------------------------------- /src/api/users.ts: -------------------------------------------------------------------------------- 1 | import axios from '../libs/axios'; 2 | import { 3 | Post, 4 | Series, 5 | Question, 6 | UserItem, 7 | TagItem, 8 | SocialAccount, 9 | Request, 10 | PagedResource 11 | } from '../types/api'; 12 | 13 | interface Profile extends UserItem { 14 | social_accounts: SocialAccount[]; 15 | } 16 | 17 | export const getProfile = (username: string, params?: object): Promise => 18 | axios.get(`/users/${username}`, { params }).then(_ => _.data); 19 | 20 | const associatedResource = (type: string) => 21 | (username: string, params?: Request): Promise> => 22 | axios.get(`/users/${username}/${type}`, { params }).then(_ => _.data); 23 | 24 | export const getUserPosts = associatedResource('posts'); 25 | export const getUserClips = associatedResource('clips'); 26 | export const getUserQuestions = associatedResource('questions'); 27 | export const getUserSeries = associatedResource('series'); 28 | export const getUserFollowers = associatedResource('followers'); 29 | export const getUserFollowings = associatedResource('followings'); 30 | export const getUserFollowingTags = associatedResource('following-tags'); 31 | -------------------------------------------------------------------------------- /src/api/comments.ts: -------------------------------------------------------------------------------- 1 | import axios from '../libs/axios'; 2 | import { PagedResource } from '../types/api'; 3 | import { CommentableType } from '../libs/interactions'; 4 | 5 | interface Comment { 6 | id: number; 7 | hash_id: string; 8 | user_id: number; 9 | contents: string; 10 | level: number; 11 | points: number; 12 | rated_value: number | null; 13 | commentable_type: string; 14 | commentable_id: number; 15 | in_reply_to_comment: number | null; 16 | in_reply_to_user: number | null; 17 | created_at: string; 18 | updated_at: string; 19 | } 20 | 21 | interface CommentInput { 22 | comment_contents: string; 23 | ancestor_id: number; 24 | } 25 | 26 | export const getComments = (commentableType: CommentableType, hashId: string): Promise> => 27 | axios.get(`/${commentableType}/${hashId}/comments`).then(_ => _.data); 28 | 29 | export const postComment = (commentableType: CommentableType, hashId: string, input: CommentInput) => 30 | axios.post(`/${commentableType}/${hashId}/comments`, input); 31 | 32 | export const updateComment = (hashId: string, input: {comment_contents: string}) => 33 | axios.put(`/comments/${hashId}`, input); 34 | 35 | export const deleteComment = (hashId: string) => axios.delete(`/comments/${hashId}`); 36 | -------------------------------------------------------------------------------- /src/echo/index.ts: -------------------------------------------------------------------------------- 1 | // Why, Laravel echo? Whyyyyyyyyyyyyy ლ(ಠ益ಠ)ლ 2 | import Echo from 'laravel-echo'; 3 | import { getCurrentToken, OAuthToken } from '../auth'; 4 | import PrivateChannel from './PrivateChannel'; 5 | import NewPostsChannel from './NewPostsChannel'; 6 | 7 | const defaultOptions = { 8 | host: 'https://viblo.asia:6001', 9 | broadcaster: 'socket.io', 10 | namespace: 'Framgia.Viblo.Events', 11 | reconnectionAttempts: 2, 12 | reconnectionDelay: 5000 13 | }; 14 | 15 | const setAuthorizationHeader = (token: OAuthToken, options: object) => ({ 16 | ...options, 17 | auth: { 18 | headers: { 19 | authorization: `${token.token_type} ${token.access_token}` 20 | } 21 | } 22 | }); 23 | 24 | export function newConnection(options?: object): Echo { 25 | const token = getCurrentToken(); 26 | 27 | const baseOptions = token 28 | ? setAuthorizationHeader(token, defaultOptions) 29 | : defaultOptions; 30 | 31 | return new Echo({ 32 | ...baseOptions, 33 | ...options 34 | }); 35 | } 36 | 37 | export function joinPrivateChannel(userId: number, connection: Echo): PrivateChannel { 38 | return new PrivateChannel(userId, connection); 39 | } 40 | 41 | export function joinNewPostsChannel(connection: Echo): NewPostsChannel { 42 | return new NewPostsChannel(connection); 43 | } 44 | -------------------------------------------------------------------------------- /src/api/questions.ts: -------------------------------------------------------------------------------- 1 | import axios from '../libs/axios'; 2 | import { 3 | Question, 4 | QuestionFull, 5 | Answer, 6 | Request, 7 | PagedResource 8 | } from '../types/api'; 9 | 10 | export enum QuestionFeedType { 11 | Newest = 'newest', 12 | Unsolved = 'unsolved', 13 | Unanswered = 'unanswered', 14 | Following = 'followings', 15 | Clipped = 'clips' 16 | } 17 | 18 | export const getQuestionsFeed = (feed: QuestionFeedType, params?: Request): Promise> => 19 | axios.get('/questions', { params: { feed, ...params } }).then(_ => _.data); 20 | 21 | export const getQuestion = (hashId: string): Promise => 22 | axios.get(`/questions/${hashId}`).then(_ => _.data); 23 | 24 | export const getAnswers = (hashId: string, params: Request): Promise> => 25 | axios.get(`/questsions/${hashId}/answers`, { params }).then(_ => _.data); 26 | 27 | export const acceptAnswer = (answer: string, value: boolean) => axios.put(`/answers/${answer}/accept`, { value }); 28 | 29 | export const postQuestion = (input: object) => axios.post('/questions', input); 30 | export const getQuestionForEdit = (hashId: string) => axios.get(`/questions/${hashId}/edit`); 31 | export const updateQuestion = (hashId: string, input: object) => axios.put(`/questions/${hashId}`, input); 32 | export const deleteQuestion = (hashId: string) => axios.delete(`/questions/${hashId}`); 33 | -------------------------------------------------------------------------------- /src/api/series.ts: -------------------------------------------------------------------------------- 1 | import axios from '../libs/axios'; 2 | import { Series, SeriesFull, Post, Request, PagedResource } from '../types/api'; 3 | 4 | export const getSeriesFeed = (params?: Request): Promise> => 5 | axios.get('/series', { params }).then(_ => _.data); 6 | 7 | export const getSeries = (hashId: string): Promise => 8 | axios.get(`/series/${hashId}`).then(_ => _.data); 9 | 10 | export const createSeries = (values: object) => axios.post('/series', values); 11 | export const edit = (hashId: string) => axios.get(`/series/${hashId}/edit`).then(_ => _.data); 12 | export const updateSeries = (hashId: string, values: object) => axios.put(`/series/${hashId}`, values); 13 | export const deleteSeries = (hashId: string) => axios.delete(`/series/${hashId}`); 14 | 15 | export const getPosts = (hashId: string, params?: Request): Promise> => 16 | axios.get(`/series/${hashId}/posts`).then(_ => _.data); 17 | 18 | export const addPost = (postId: string, series: string) => 19 | axios.put(`/series/${series}/addPost`, { post_id: postId }); 20 | 21 | export const removePost = (postId: string, series: string) => 22 | axios.put(`/series/${series}/removePost`, { post_id: postId }); 23 | 24 | export const movePostBefore = (nextPostId: string, postId: string, series: string) => 25 | axios.put(`/series/${series}/movePostBefore`, { 26 | next_post_id: nextPostId, 27 | post_id: postId 28 | }); 29 | -------------------------------------------------------------------------------- /src/markdown/plugins/katex/katex.ts: -------------------------------------------------------------------------------- 1 | import katex = require('katex'); 2 | import { MarkdownIt } from 'markdown-it'; 3 | import { escape } from '../../utils'; 4 | import inlineRule from './math_inline'; 5 | import blockRule from './math_block'; 6 | 7 | function renderErrorMessage(message: string) { 8 | return `

${message}

`; 9 | } 10 | 11 | function render(content: string, options: any) { 12 | try { 13 | if (content.length <= options.maxCharacter) { 14 | return options.displayMode 15 | ? `

${katex.renderToString(content, options)}

` 16 | : katex.renderToString(content, options); 17 | } 18 | 19 | return renderErrorMessage( 20 | `For performance reasons, math blocks are limited to ${options.maxCharacter} characters.` 21 | + ' Try splitting up this block, or include an image instead.' 22 | ); 23 | } catch (error) { 24 | return escape(content); 25 | } 26 | } 27 | 28 | export default function (md: MarkdownIt, options: object) { 29 | md.inline.ruler.push('math_inline', inlineRule); 30 | md.block.ruler.after('blockquote', 'math_block', blockRule, { 31 | alt: ['paragraph', 'reference', 'blockquote', 'list'] 32 | }); 33 | 34 | md.renderer.rules.math_inline = (tokens, idx) => render(tokens[idx].content, { ...options, displayMode: false }); 35 | md.renderer.rules.math_block = (tokens, idx) => render(tokens[idx].content, { ...options, displayMode: true }); 36 | } 37 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "viblo-sdk", 3 | "version": "0.1.0-beta.34", 4 | "main": "sdk.js", 5 | "license": "MIT", 6 | "repository": { 7 | "type": "git", 8 | "url": "https://github.com/viblo-asia/sdk-js" 9 | }, 10 | "homepage": "https://viblo.asia", 11 | "scripts": { 12 | "build:copy": "node ./scripts/copy-files.js", 13 | "build": "yarn compile && yarn build:copy", 14 | "compile": "rimraf dist && tsc", 15 | "dev": "rimraf dist && yarn build:copy && tsc -w", 16 | "docs": "typedoc --out docs --target es6 --theme minimal --name 'Viblo Javascript SDK' --includeDeclarations --externalPattern node_module/* --excludeExternals src", 17 | "lint": "tslint -c tslint.json 'src/**/*.ts'", 18 | "release": "np --yolo --contents=dist", 19 | "version": "yarn build" 20 | }, 21 | "devDependencies": { 22 | "@types/node": "^10.5.2", 23 | "@types/prismjs": "^1.9.0", 24 | "@types/sanitize-html": "^2.5.0", 25 | "fs-extra": "^6.0.1", 26 | "np": "^7.4.0", 27 | "rimraf": "^2.6.2", 28 | "tslint": "^5.10.0", 29 | "tslint-config-airbnb": "^5.9.2", 30 | "typedoc": "^0.20.30", 31 | "typescript": "^4.1.2" 32 | }, 33 | "dependencies": { 34 | "axios": "^0.21.1", 35 | "clipboard": "^2.0.8", 36 | "katex": "0.13.18", 37 | "laravel-echo": "^1.6.1", 38 | "lodash.escape": "^4.0.1", 39 | "markdown-it": "^8.4.2", 40 | "markdown-it-emoji": "^1.4.0", 41 | "markdown-it-sanitizer": "https://github.com/viblo-asia/markdown-it-sanitizer#33dd3f7", 42 | "prismjs": "^1.15.0", 43 | "sanitize-html": "^2.5.2", 44 | "twemoji": "14.0.2" 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/markdown/plugins/katex/math_inline.ts: -------------------------------------------------------------------------------- 1 | import { StateInline } from 'markdown-it'; 2 | 3 | function skip(state: StateInline, openingLength: number) { 4 | state.pending += '$'.repeat(openingLength); 5 | state.pos += openingLength; 6 | 7 | return true; 8 | } 9 | 10 | function findClosing(state: StateInline, start: number): number { 11 | const match = state.src.slice(start).match(/(^|[^\\])(\\\\)*\$/); 12 | 13 | if (match && match.index !== undefined) { 14 | const found = start + match.index + match[0].length - 1; 15 | 16 | const closing = state.scanDelims(found, false); 17 | 18 | if (closing.can_close) { 19 | return found; 20 | } 21 | } 22 | 23 | return -1; 24 | } 25 | 26 | export default function (state: StateInline, silent: boolean) { 27 | if (silent || state.src[state.pos] !== '$') { 28 | return false; 29 | } 30 | 31 | const opening = state.scanDelims(state.pos, false); 32 | 33 | if (opening.length > 1 || !opening.can_open) { 34 | return skip(state, opening.length); 35 | } 36 | 37 | const start = state.pos + opening.length; 38 | const closingIndex = findClosing(state, start); 39 | 40 | if (closingIndex === -1) { 41 | return skip(state, opening.length); 42 | } 43 | 44 | const content = state.src.slice(start, closingIndex); 45 | 46 | if (!silent) { 47 | const token = state.push('math_inline', 'math', 0); 48 | token.markup = '$'; 49 | token.content = state.src.slice(state.pos + opening.length, closingIndex); 50 | } 51 | 52 | state.pos += opening.length + content.length + 1; 53 | 54 | return true; 55 | } 56 | -------------------------------------------------------------------------------- /src/markdown/plugins/embed.ts: -------------------------------------------------------------------------------- 1 | import { MarkdownIt, StateInline, Token } from 'markdown-it'; 2 | import { renderEmbed } from '../utils'; 3 | 4 | const _escape = require('lodash.escape'); 5 | 6 | const regexp = /{@(embed|gist|vimeo|codepen|youtube|jsfiddle|slideshare|googleslide)\s*:\s*([\S]+?)}/; 7 | 8 | function parse(state: StateInline) { 9 | if (state.src.charCodeAt(state.pos) !== 123) return false; 10 | 11 | const match = regexp.exec(state.src.slice(state.pos)); 12 | 13 | if (!match) return false; 14 | 15 | const provider = match[1] === 'embed' ? null : match[1]; 16 | const url = match[2]; 17 | const token = state.push('at-embed', 'embed', state.level); 18 | 19 | token.meta = { provider }; 20 | token.content = url; 21 | state.pos += match[0].length; 22 | 23 | return true; 24 | } 25 | 26 | const render = (options: EmbedOptions) => function (tokens: Token[], idx: number) { 27 | const token = tokens[idx]; 28 | const baseURL = options.baseURL; 29 | const provider = token.meta.provider; 30 | const url = token.content; 31 | 32 | return renderEmbed({ 33 | type: 'text/html', 34 | src: _escape(`${baseURL}/embed?url=${url}&provider=${provider}`), 35 | frameborder: 0, 36 | webkitallowfullscreen: true, 37 | mozallowfullscreen: true, 38 | allowfullscreen: true 39 | }, options); 40 | }; 41 | 42 | export const createPlugin = (options: EmbedOptions) => function (md: MarkdownIt) { 43 | md.inline.ruler.push('at-embed', parse); 44 | md.renderer.rules['at-embed'] = render(options); 45 | }; 46 | 47 | export interface EmbedOptions { 48 | baseURL?: string; 49 | wrapperClass?: string; 50 | iframeClass?: string; 51 | } 52 | -------------------------------------------------------------------------------- /src/markdown/utils.ts: -------------------------------------------------------------------------------- 1 | import { EmbedOptions } from './plugins/embed'; 2 | import { MarkdownIt, Token, Renderer } from 'markdown-it'; 3 | 4 | type AlterTokenFunction = (token: Token) => Token; 5 | 6 | export function alterToken(rule: string, alter: AlterTokenFunction, md: MarkdownIt): MarkdownIt { 7 | const renderer = md.renderer.rules[rule] 8 | || ((tokens, idx, options, env, self) => self.renderToken(tokens, idx, options)); 9 | 10 | md.renderer.rules[rule] = (tokens: Token[], idx: number, options, env, self: Renderer) => { 11 | const token = tokens[idx]; 12 | const alteredToken = alter(token); 13 | tokens[idx] = alteredToken; 14 | 15 | return renderer(tokens, idx, options, env, self); 16 | }; 17 | 18 | return md; 19 | } 20 | 21 | /** 22 | * https://github.com/lodash/lodash/blob/master/escape.js 23 | */ 24 | const htmlEscapes = { 25 | '&': '&', 26 | '<': '<', 27 | '>': '>', 28 | '"': '"', 29 | '\'': ''' 30 | }; 31 | 32 | /** Used to match HTML entities and HTML characters. */ 33 | const reUnescapedHtml = /[&<>"']/g; 34 | const reHasUnescapedHtml = RegExp(reUnescapedHtml.source); 35 | 36 | export function escape(string) { 37 | return (string && reHasUnescapedHtml.test(string)) 38 | ? string.replace(reUnescapedHtml, chr => htmlEscapes[chr]) 39 | : string; 40 | } 41 | 42 | export function renderEmbed(attrs: object, options: EmbedOptions) { 43 | const iframeAttrs = Object.keys(attrs) 44 | .map((key) => { 45 | const value = attrs[key]; 46 | 47 | return value === true ? key : `${key}="${attrs[key]}"`; 48 | }) 49 | .join(' '); 50 | 51 | const iframeClassAttr = options.iframeClass ? `class="${options.iframeClass}"` : ''; 52 | const iframe = ``; 53 | 54 | const wrapperClassAttr = options.wrapperClass ? `class="${options.wrapperClass}"` : ''; 55 | 56 | return `
${iframe}
`; 57 | } 58 | -------------------------------------------------------------------------------- /themes/clipboard-code.scss: -------------------------------------------------------------------------------- 1 | .v-markdown-it-code-copy { 2 | position: absolute; 3 | display: flex; 4 | align-items: center; 5 | justify-content: center; 6 | top: 7.5px; 7 | right: 6px; 8 | cursor: pointer; 9 | outline: none; 10 | border: none; 11 | background: #655454; 12 | height: 25px; 13 | opacity: 0.5; 14 | border-radius: 5px; 15 | 16 | &:hover { 17 | opacity: 1; 18 | } 19 | } 20 | 21 | .v-markdown-icon { 22 | font-size: 16px; 23 | color: #fff 24 | } 25 | 26 | .v-markdown-tooltip .tooltiptext { 27 | width: 100px; 28 | font-size: 0.75rem; 29 | background-color: black; 30 | color: #fff; 31 | text-align: center; 32 | padding: 5px; 33 | border-radius: 6px; 34 | position: absolute; 35 | z-index: 1; 36 | } 37 | 38 | .v-markdown-tooltip:hover .tooltiptext { 39 | display: block !important; 40 | } 41 | 42 | .v-markdown-tooltip .tooltiptext { 43 | right: 105%; 44 | background: #655454; 45 | } 46 | 47 | .v-markdown-content-box { 48 | max-height: 300px; 49 | padding-bottom: 1rem; 50 | 51 | &:not(&--expanded) { 52 | cursor: pointer; 53 | mask-image: linear-gradient(180deg,#000 60%,transparent); 54 | } 55 | 56 | &--expanded { 57 | max-height: initial !important; 58 | } 59 | } 60 | 61 | .editor-preview-side { 62 | .v-markdown-content-box { 63 | max-height: initial !important; 64 | mask-image: none !important 65 | } 66 | 67 | .button-more { 68 | display: none !important; 69 | } 70 | } 71 | 72 | 73 | .v-content-flex-center { 74 | display: flex; 75 | align-items: center; 76 | justify-content: center; 77 | } 78 | 79 | .v-markdown-it-show-more { 80 | position: absolute; 81 | bottom: 50%; 82 | cursor: pointer; 83 | outline: none; 84 | border: none; 85 | background: #655454; 86 | color: #fff; 87 | opacity: 0.8; 88 | border-radius: 5px; 89 | 90 | &:hover { 91 | opacity: 1; 92 | } 93 | 94 | .show-more-text { 95 | font-size: 16px; 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /src/types/api.d.ts: -------------------------------------------------------------------------------- 1 | export interface Request { 2 | page: number; 3 | limit: number; 4 | } 5 | 6 | export interface LengthAwarePaginator { 7 | count: number; 8 | current_page: number; 9 | links: any; 10 | per_page: number; 11 | total: number; 12 | total_pages: number; 13 | } 14 | 15 | export interface Resource { 16 | data: T; 17 | } 18 | 19 | export interface PagedResource { 20 | data: T[]; 21 | meta: { 22 | pagination: LengthAwarePaginator; 23 | }; 24 | } 25 | 26 | export interface Tag { 27 | slug: string; 28 | name: string; 29 | primary: boolean; 30 | } 31 | 32 | export interface TagItem extends Tag { 33 | posts_count: number; 34 | questions_count: number; 35 | followers_count: number; 36 | image: string; 37 | following: boolean | undefined; 38 | } 39 | 40 | export interface User { 41 | id: number; 42 | username: string; 43 | name: string; 44 | avatar: string[]; 45 | } 46 | 47 | export interface UserItem extends User { 48 | posts_count: number; 49 | questions_count: number; 50 | answers_count: number; 51 | followers_count: number; 52 | reputation: number; 53 | } 54 | 55 | export interface SocialAccount { 56 | service: string; 57 | url: string; 58 | public: boolean; 59 | } 60 | 61 | export interface Post { 62 | id: number; 63 | hash_id: string; 64 | user_id: string; 65 | title: string; 66 | locale_code: string; 67 | points: number; 68 | clipped: boolean; 69 | rated_value: number | null; 70 | promoted: boolean; 71 | promoted_at: string; 72 | trending: boolean; 73 | trend_at: string; 74 | title_slug: string; 75 | clips_count: number; 76 | views_count: number; 77 | comments_count: number; 78 | user: Resource; 79 | tags: Resource; 80 | created_at: string; 81 | updated_at: string; 82 | edited_at: string; 83 | } 84 | 85 | export interface PostFull extends Post { 86 | 87 | } 88 | 89 | export interface Series { 90 | 91 | } 92 | 93 | export interface SeriesFull extends Series { 94 | 95 | } 96 | 97 | export interface Question { 98 | 99 | } 100 | 101 | export interface QuestionFull { 102 | 103 | } 104 | 105 | export interface Answer { 106 | 107 | } 108 | -------------------------------------------------------------------------------- /src/api/me.ts: -------------------------------------------------------------------------------- 1 | import axios from '../libs/axios'; 2 | import { Request, PagedResource } from '../types/api'; 3 | import { SubscribableType } from '../libs/interactions'; 4 | 5 | interface UserSelf { 6 | id: string; 7 | name: string; 8 | email: string; 9 | username: string; 10 | avatar: string[]; 11 | roles: string[]; 12 | is_admin: boolean; 13 | reputation: number; 14 | } 15 | 16 | interface UploadedFile { 17 | id: string; 18 | name: string; 19 | path: string; 20 | } 21 | 22 | export const self = (): Promise => axios.get('/me').then(_ => _.data); 23 | 24 | // Draft contents 25 | 26 | export const getDrafts = params => axios.get('/me/contents/drafts', { params }).then(_ => _.data); 27 | 28 | // Uploaded files 29 | export const getImages = (params?: Request): Promise> => 30 | axios.get('/me/images', { params }).then(_ => _.data); 31 | 32 | export const deleteImage = (uuid: string) => axios.delete(`/me/images/${uuid}`); 33 | 34 | // Notifications 35 | export const getNotifications = (params?: object) => axios.get('/me/notifications', { params }).then(_ => _.data); 36 | export const clearNotifications = (params?: object): Promise => axios.post('/me/notifications/clear', { params }); 37 | 38 | // Profile 39 | export const getProfile = () => axios.get('/me/settings/profile').then(_ => _.data); 40 | export const updateProfile = (input: object) => axios.post('/me/settings/profile', input); 41 | 42 | export const changePassword = (input: object) => axios.put('/me/settings/password', input); 43 | 44 | export const getConnectedAccounts = () => axios.get('/me/settings/socials').then(_ => _.data); 45 | export const disconnectSocialAccount = (service: string) => axios.delete(`/social/${service}/disconnect`); 46 | export const setSocialPrivacy = (service: string , value: boolean) => 47 | axios.put('/me/settings/socialPrivacy', { service, value }); 48 | 49 | export const getNotificationSettings = () => axios.get('/me/settings/notification').then(_ => _.data); 50 | export const getServiceSettings = () => axios.get('/me/settings/service').then(_ => _.data); 51 | 52 | export const updateSettings = (name, value) => axios.put('/me/settings', { [name]: value }); 53 | 54 | // Subscriptions 55 | export function subscribe(type: SubscribableType, key: string, value: boolean) { 56 | const url = `/me/subscriptions/${type}/${key}`; 57 | return value ? axios.put(url) : axios.delete(url); 58 | } 59 | -------------------------------------------------------------------------------- /themes/prism-github.scss: -------------------------------------------------------------------------------- 1 | /** 2 | * GitHub like syntax coloring 3 | */ 4 | 5 | code[class*='language-'], 6 | pre[class*='language-'] { 7 | color: #24292e; 8 | font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, Courier, monospace; 9 | direction: ltr; 10 | text-align: left; 11 | white-space: pre; 12 | word-spacing: normal; 13 | word-break: normal; 14 | line-height: 1.45; 15 | 16 | tab-size: 4; 17 | hyphens: none; 18 | } 19 | 20 | pre[class*='language-']::-moz-selection, 21 | pre[class*='language-'] ::-moz-selection, 22 | code[class*='language-']::-moz-selection, 23 | code[class*='language-'] ::-moz-selection { 24 | background: #b3d4fc; 25 | } 26 | 27 | pre[class*='language-']::selection, 28 | pre[class*='language-'] ::selection, 29 | code[class*='language-']::selection, 30 | code[class*='language-'] ::selection { 31 | background: #b3d4fc; 32 | } 33 | 34 | pre[class*='language-'] { 35 | padding: 1em; 36 | overflow: auto; 37 | background-color: #f6f8fa; 38 | border-radius: 3px; 39 | } 40 | 41 | .token { 42 | &.delimiter, 43 | &.entity, 44 | &.url, 45 | &.variable, 46 | &.constant { 47 | color: #24292e; 48 | } 49 | 50 | &.atrule, 51 | &.namespace, 52 | &.operator, 53 | &.keyword { 54 | color: #d73a49; 55 | } 56 | 57 | &.package, 58 | &.number, 59 | &.boolean, 60 | &.symbol { 61 | color: #005cc5; 62 | } 63 | 64 | &.tag, 65 | &.selector { 66 | color: #22863a; 67 | } 68 | 69 | &.function, 70 | &.class-name, 71 | &.attr-name { 72 | color: #6f42c1; 73 | } 74 | 75 | &.string, 76 | &.attr-value, 77 | &.property, 78 | &.regex { 79 | color: #032f62; 80 | } 81 | 82 | &.comment, 83 | &.prolog, 84 | &.doctype, 85 | &.cdata { 86 | color: #6a737d; 87 | } 88 | } 89 | 90 | .token.bold { 91 | font-weight: bold; 92 | } 93 | 94 | .token.italic { 95 | font-style: italic; 96 | } 97 | 98 | .language-diff { 99 | .token { 100 | &.inserted { 101 | color: #22863a; 102 | background-color: #f0fff4; 103 | } 104 | 105 | &.deleted { 106 | color: #b31d28; 107 | background-color: #ffeef0; 108 | } 109 | 110 | &.important { 111 | color: #e36209; 112 | background-color: #ffebda; 113 | } 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /src/markdown/plugins/katex/math_block.ts: -------------------------------------------------------------------------------- 1 | import { StateBlock } from 'markdown-it'; 2 | 3 | export default function (state: StateBlock, start: number, end: number, silent: boolean) { 4 | let firstLine; 5 | 6 | const firstLineOffsets = getLineOffsets(start, state); 7 | const firstLineStart = firstLineOffsets.start; 8 | const firstLineEnd = firstLineOffsets.end; 9 | 10 | // Too short 11 | if (firstLineStart + 2 > firstLineEnd) { 12 | return false; 13 | } 14 | 15 | // Not a valid Opening 16 | if (state.src.slice(firstLineStart, firstLineStart + 2) !== '$$') { 17 | return false; 18 | } 19 | 20 | firstLine = state.src.slice(firstLineStart + 2, firstLineEnd); 21 | 22 | // Don't check for closing if in silent mode 23 | if (silent) { 24 | return true; 25 | } 26 | 27 | let lastLine; 28 | let current = start; 29 | // Single line expression 30 | if (firstLine.trim().slice(-2) === '$$') { 31 | firstLine = firstLine.trim().slice(0, -2); 32 | } else { 33 | const lastLineIndex = findBlockLastLine(start, end, state); 34 | 35 | if (lastLineIndex) { 36 | current = lastLineIndex; 37 | 38 | const lastLineOffsets = getLineOffsets(current, state); 39 | const first = lastLineOffsets.start; 40 | const last = lastLineOffsets.end; 41 | 42 | lastLine = state.src.slice(first, last).trim().slice(0, -2); 43 | } 44 | } 45 | 46 | state.line = current + 1; 47 | 48 | const token = state.push('math_block', 'math', 0); 49 | token.block = true; 50 | token.content = (firstLine && firstLine.trim() ? `${firstLine}\n` : '') + 51 | state.getLines(start + 1, current, state.tShift[start], true) + 52 | (lastLine && lastLine.trim() ? lastLine : ''); 53 | token.map = [start, state.line]; 54 | token.markup = '$$'; 55 | 56 | return true; 57 | } 58 | 59 | const getLineOffsets = (line, state) => ({ 60 | start: state.bMarks[line] + state.tShift[line], 61 | end: state.eMarks[line] 62 | }); 63 | 64 | function findBlockLastLine(start: number, end: number, state: StateBlock) { 65 | let current = start; 66 | 67 | while (current < end) { 68 | current += 1; 69 | 70 | const lineOffsets = getLineOffsets(current, state); 71 | const first = lineOffsets.start; 72 | const last = lineOffsets.end; 73 | 74 | if (first < last && state.tShift[current] < state.blkIndent) { 75 | // non-empty line with negative indent should stop the list: 76 | break; 77 | } 78 | 79 | if (state.src.slice(first, last).trim().slice(-2) === '$$') { 80 | return current; 81 | } 82 | } 83 | 84 | return null; 85 | } 86 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Viblo Javascript SDK 2 | 3 | [![Build Status](https://travis-ci.org/viblo-asia/sdk-js.svg?branch=master)](https://travis-ci.org/viblo-asia/sdk-js) 4 | [![npm version](https://badge.fury.io/js/viblo-sdk.svg)](https://badge.fury.io/js/viblo-sdk) 5 | 6 | --- 7 | ## Installing 8 | 9 | ```bash 10 | npm install --save viblo-sdk 11 | ``` 12 | 13 | ## Basic usage 14 | 15 | ### Markdown 16 | 17 | Create folder libs include file **markdown.js** (libs/markdown.js) 18 | 19 | ```javascript 20 | import { createRenderer } from 'viblo-sdk/markdown'; 21 | 22 | const md = createRenderer({ 23 | baseURL: 'http://localhost:3000', 24 | absoluteURL: false, 25 | embed: { 26 | wrapperClass: 'embed-responsive embed-responsive-16by9', 27 | iframeClass: 'embed-responsive-item', 28 | }, 29 | katex: { 30 | maxSize: 500, 31 | maxExpand: 100, 32 | maxCharacter: 1000, 33 | }, 34 | }); 35 | 36 | export default md; 37 | ``` 38 | 39 | ### Reactjs: 40 | 41 | Create components **index.js** (components/Markdown/index.js) 42 | 43 | ```jsx 44 | import React from 'react'; 45 | import PropTypes from 'prop-types' 46 | import md from './libs/markdown'; 47 | 48 | const Markdown = ({ markdown }) => { 49 | const rawHtml = md.render(markdown); 50 | 51 | return ( 52 |
53 | ); 54 | } 55 | 56 | Markdown.propTypes = { 57 | markdown: PropTypes.string, 58 | } 59 | 60 | Markdown.defaultProps = { 61 | markdown: '', 62 | } 63 | 64 | export default Markdown; 65 | 66 | ``` 67 | 68 | Create page **index.js** 69 | 70 | ```jsx 71 | import Markdown from './components/Markdown' 72 | 73 | const Preview = () => { 74 | return ( 75 | 76 | ); 77 | } 78 | 79 | export default Preview; 80 | ``` 81 | 82 | ### Vuejs: 83 | 84 | Create components **Mardown.vue** (components/Mardown.vue) 85 | 86 | ```jsx 87 | 90 | 91 | 109 | ``` 110 | 111 | Create page **index.vue** 112 | 113 | ```jsx 114 | 117 | { 29 | clipboardButton.classList.remove('v-markdown-tooltip'); 30 | }, Number(clipboardButton.getAttribute('delay'))); 31 | }); 32 | } catch (e) { 33 | // 34 | } 35 | 36 | function renderCode(origRule, options: Options) { 37 | return (...args) => { 38 | const [tokens, idx] = args; 39 | const content = typeof tokens[idx].content.replaceAll === 'function' ? tokens[idx].content 40 | .replaceAll('"', '"') 41 | .replaceAll("'", ''') : []; 42 | const origRendered = origRule(...args); 43 | 44 | if (content.length === 0) { 45 | return origRendered; 46 | } 47 | 48 | // check height for code block 49 | const context = document.createElement('div'); 50 | context.innerHTML = origRendered; 51 | context.classList.add('md-contents'); 52 | document.body.appendChild(context); 53 | const { clientHeight } = context; 54 | document.body.removeChild(context); 55 | const isShortCut = clientHeight > options.maxStringLengthShortcut; 56 | const showMoreElement = isShortCut ? 57 | ` 58 |
59 | 66 |
` : ''; 67 | 68 | return ` 69 |
76 | ${origRendered} 77 | 86 | ${showMoreElement} 87 |
88 | `; 89 | }; 90 | } 91 | 92 | export default function (md: Markdown.MarkdownIt, options: Options) { 93 | md.renderer.rules.code_block = renderCode(md.renderer.rules.code_block, options); 94 | md.renderer.rules.fence = renderCode(md.renderer.rules.fence, options); 95 | } 96 | -------------------------------------------------------------------------------- /src/markdown/index.ts: -------------------------------------------------------------------------------- 1 | import twemoji from 'twemoji/dist/twemoji.esm'; 2 | import Markdown = require('markdown-it'); 3 | import emoji = require('markdown-it-emoji'); 4 | import * as sanitizeHtml from 'sanitize-html'; 5 | import sanitize = require('markdown-it-sanitizer'); 6 | 7 | import katex from './plugins/katex/katex'; 8 | import highlight from './plugins/highlight'; 9 | import clipboard from './plugins/clipboard'; 10 | import { alterToken } from './utils'; 11 | import { createPlugin as createEmbedPlugin } from './plugins/embed'; 12 | import { createDefinition as createMentionPlugin } from './plugins/linkify-mention'; 13 | 14 | export interface EmbedOptions { 15 | wrapperClass?: string; 16 | iframeClass?: string; 17 | } 18 | 19 | interface KatexOptions { 20 | maxSize?: Number; 21 | maxExpand?: Number; 22 | maxCharacter?: Number; 23 | } 24 | 25 | export interface ClipboardOptions { 26 | iconCopyClass?: string; 27 | successText?: string; 28 | successTextDelay: Number; 29 | buttonClass?: string; 30 | contentClass?: string; 31 | titleButton?: string; 32 | showMoreText?: string; 33 | showMoreClass?: string; 34 | showMoreIcon?: string; 35 | maxStringLengthShortcut: Number; 36 | } 37 | 38 | export interface Options { 39 | /** Base URL */ 40 | baseURL?: string; 41 | /** Whether to add mention link or not */ 42 | mention?: boolean; 43 | /** Whether to render embedments or not */ 44 | embed?: boolean | EmbedOptions; 45 | /** Should relative URLs be made to absolute */ 46 | absoluteURL?: boolean; 47 | /** Katex Options */ 48 | katex: KatexOptions; 49 | /** Clipboard Options */ 50 | clipboard: ClipboardOptions; 51 | } 52 | 53 | const defaultOptions: Options = { 54 | baseURL: 'https://viblo.asia', 55 | mention: true, 56 | embed: true, 57 | absoluteURL: true, 58 | katex: { 59 | maxSize: 500, 60 | maxExpand: 100, 61 | maxCharacter: 1000, 62 | }, 63 | clipboard: { 64 | iconCopyClass: 'el-icon-document-copy', 65 | successText: 'Copied ✔️', 66 | successTextDelay: 2000, 67 | buttonClass: 'v-markdown-it-code-copy', 68 | contentClass: 'v-markdown-content-box', 69 | titleButton: 'Copy', 70 | showMoreText: 'Show more', 71 | showMoreClass: 'v-markdown-it-show-more', 72 | showMoreIcon: 'el-icon-bottom', 73 | maxStringLengthShortcut: 300 74 | } 75 | }; 76 | 77 | const sanitizeOptions: sanitizeHtml.IOptions = { 78 | allowedTags: false, 79 | allowedAttributes: false 80 | }; 81 | 82 | export function createRenderer(options: Options) { 83 | const _options = Object.assign({}, defaultOptions, options); 84 | const _katexOptions = typeof _options.katex === 'object' ? _options.katex : defaultOptions.katex; 85 | 86 | const _clipboardOptions = typeof _options.clipboard === 'object' 87 | ? Object.assign({}, defaultOptions.clipboard, _options.clipboard) 88 | : defaultOptions.clipboard; 89 | 90 | const md = Markdown({ 91 | html: true, 92 | linkify: true 93 | }); 94 | 95 | md.use(emoji); 96 | md.use(highlight); 97 | md.use(clipboard, _clipboardOptions); 98 | md.renderer.rules.emoji = (token, idx) => twemoji.parse(token[idx].content); 99 | 100 | md.use(katex, { 101 | throwOnError: true, 102 | ..._katexOptions, 103 | }); 104 | 105 | alterToken('link_open', (token) => { 106 | token.attrPush(['target', '_blank']); 107 | 108 | if (_options.absoluteURL) { 109 | const href = token.attrGet('href'); 110 | if (href && href.startsWith('/')) { 111 | token.attrSet('href', `${_options.baseURL}${href}`); 112 | } 113 | } 114 | 115 | return token; 116 | }, md); 117 | 118 | if (_options.mention !== false) { 119 | md.linkify.add('@', createMentionPlugin(`${_options.baseURL}/u`)); 120 | } 121 | 122 | if (_options.embed !== false) { 123 | const embedOptions = typeof _options.embed === 'object' ? _options.embed : {}; 124 | 125 | const embedPlugin = createEmbedPlugin({ 126 | ...embedOptions, 127 | baseURL: _options.baseURL 128 | }); 129 | 130 | md.use(embedPlugin); 131 | } 132 | 133 | md.use(sanitize, { align: true }); 134 | 135 | const originalRender = md.render; 136 | md.render = (markdownContent) => { 137 | return sanitizeHtml(originalRender.call(md, markdownContent), sanitizeOptions); 138 | }; 139 | 140 | return md; 141 | } 142 | -------------------------------------------------------------------------------- /src/markdown/plugins/highlight.ts: -------------------------------------------------------------------------------- 1 | import Prism = require('prismjs'); 2 | import Markdown = require('markdown-it'); 3 | import { escapeHtml } from 'markdown-it/lib/common/utils'; 4 | 5 | require('prismjs/components/prism-actionscript'); 6 | require('prismjs/components/prism-c'); 7 | require('prismjs/components/prism-cpp'); 8 | require('prismjs/components/prism-arduino'); 9 | require('prismjs/components/prism-bash'); 10 | require('prismjs/components/prism-basic'); 11 | require('prismjs/components/prism-clojure'); 12 | require('prismjs/components/prism-coffeescript'); 13 | require('prismjs/components/prism-csharp'); 14 | require('prismjs/components/prism-d'); 15 | require('prismjs/components/prism-dart'); 16 | require('prismjs/components/prism-diff'); 17 | // require('prismjs/components/prism-django'); 18 | require('prismjs/components/prism-docker'); 19 | require('prismjs/components/prism-elixir'); 20 | require('prismjs/components/prism-elm'); 21 | require('prismjs/components/prism-markup-templating'); 22 | require('prismjs/components/prism-ruby'); 23 | require('prismjs/components/prism-erb'); 24 | require('prismjs/components/prism-erlang'); 25 | require('prismjs/components/prism-gherkin'); 26 | require('prismjs/components/prism-go'); 27 | require('prismjs/components/prism-graphql'); 28 | require('prismjs/components/prism-haml'); 29 | require('prismjs/components/prism-handlebars'); 30 | require('prismjs/components/prism-haskell'); 31 | require('prismjs/components/prism-haxe'); 32 | require('prismjs/components/prism-http'); 33 | require('prismjs/components/prism-ini'); 34 | require('prismjs/components/prism-java'); 35 | require('prismjs/components/prism-json'); 36 | require('prismjs/components/prism-jsx'); 37 | require('prismjs/components/prism-kotlin'); 38 | require('prismjs/components/prism-latex'); 39 | require('prismjs/components/prism-less'); 40 | require('prismjs/components/prism-lisp'); 41 | require('prismjs/components/prism-livescript'); 42 | require('prismjs/components/prism-lua'); 43 | require('prismjs/components/prism-makefile'); 44 | require('prismjs/components/prism-matlab'); 45 | require('prismjs/components/prism-nginx'); 46 | require('prismjs/components/prism-nix'); 47 | require('prismjs/components/prism-objectivec'); 48 | require('prismjs/components/prism-pascal'); 49 | require('prismjs/components/prism-perl'); 50 | require('prismjs/components/prism-php'); 51 | require('prismjs/components/prism-sql'); 52 | require('prismjs/components/prism-plsql'); 53 | require('prismjs/components/prism-powershell'); 54 | require('prismjs/components/prism-protobuf'); 55 | require('prismjs/components/prism-python'); 56 | require('prismjs/components/prism-q'); 57 | require('prismjs/components/prism-r'); 58 | require('prismjs/components/prism-rust'); 59 | require('prismjs/components/prism-sass'); 60 | require('prismjs/components/prism-scala'); 61 | require('prismjs/components/prism-scss'); 62 | require('prismjs/components/prism-stylus'); 63 | require('prismjs/components/prism-swift'); 64 | require('prismjs/components/prism-twig'); 65 | require('prismjs/components/prism-typescript'); 66 | require('prismjs/components/prism-vbnet'); 67 | require('prismjs/components/prism-wasm'); 68 | require('prismjs/components/prism-yaml'); 69 | 70 | interface Options { 71 | langPrefix?: string; 72 | } 73 | 74 | interface ParsedLanguage { 75 | fileName: string; 76 | langName: string; 77 | } 78 | 79 | function parseLanguageToken(token: string): ParsedLanguage { 80 | const i = token.indexOf(':'); 81 | let fileName = ''; 82 | let langName = token; 83 | if (i !== -1) { 84 | fileName = token.slice(i + 1).trim(); 85 | langName = token.slice(0, i); 86 | } else if (token && token.lastIndexOf('.') !== -1) { 87 | fileName = token; 88 | langName = token.slice(token.lastIndexOf('.') + 1); 89 | } 90 | 91 | return { fileName, langName }; 92 | } 93 | 94 | function createHighlighter(options: Options) { 95 | return (str: string, lang: string) => { 96 | const { fileName, langName } = parseLanguageToken(lang); 97 | const prismLang = Prism.languages[langName.toLowerCase()]; 98 | const code = prismLang 99 | ? Prism.highlight(str, prismLang, langName) 100 | : escapeHtml(str); 101 | 102 | const languageClass = `${options.langPrefix}${langName || 'none'}`; 103 | 104 | return `
`
105 |             + `${code}`
106 |             + '
'; 107 | }; 108 | } 109 | 110 | export default function (md: Markdown.MarkdownIt) { 111 | md.options.highlight = createHighlighter({ 112 | langPrefix: md.options.langPrefix 113 | }); 114 | } 115 | -------------------------------------------------------------------------------- /src/types/markdown-it.d.ts: -------------------------------------------------------------------------------- 1 | // Markdown-it type definition 2 | // Modified from @types/markdown-it 3 | 4 | declare const MarkdownIt: MarkdownItStatic; 5 | 6 | declare module 'markdown-it' { 7 | export = MarkdownIt; 8 | } 9 | 10 | interface MarkdownItStatic { 11 | new (presetName?: 'commonmark' | 'zero' | 'default', options?: MarkdownIt.Options): MarkdownIt.MarkdownIt; 12 | new (options: MarkdownIt.Options): MarkdownIt.MarkdownIt; 13 | (presetName?: 'commonmark' | 'zero' | 'default', options ?: MarkdownIt.Options): MarkdownIt.MarkdownIt; 14 | (options: MarkdownIt.Options): MarkdownIt.MarkdownIt; 15 | } 16 | 17 | declare namespace MarkdownIt { 18 | interface MarkdownIt { 19 | options: Options; 20 | block: ParserBlock; 21 | core: Core; 22 | helpers: any; 23 | inline: ParserInline; 24 | linkify: LinkifyIt.LinkifyIt; 25 | renderer: Renderer; 26 | utils: { 27 | assign (obj: any): any; 28 | isString (obj: any): boolean; 29 | has (object: any, key: string): boolean; 30 | unescapeMd (str: string): string; 31 | unescapeAll (str: string): string; 32 | isValidEntityCode (str: any): boolean; 33 | fromCodePoint (str: string): string; 34 | escapeHtml (str: string): string; 35 | arrayReplaceAt (src: any[], pos: number, newElements: any[]): any[] 36 | isSpace (str: any): boolean; 37 | isWhiteSpace (str: any): boolean 38 | isMdAsciiPunct (str: any): boolean; 39 | isPunctChar (str: any): boolean; 40 | escapeRE (str: string): string; 41 | normalizeReference (str: string): string; 42 | }; 43 | 44 | render (md: string, env?: any): string; 45 | renderInline (md: string, env?: any): string; 46 | parse (src: string, env: any): Token[]; 47 | parseInline (src: string, env: any): Token[]; 48 | use (plugin: any, ...params: any[]): MarkdownIt; 49 | disable (rules: string[] | string, ignoreInvalid?: boolean): MarkdownIt; 50 | enable (rules: string[] | string, ignoreInvalid?: boolean): MarkdownIt; 51 | set (options: Options): MarkdownIt; 52 | normalizeLink (url: string): string; 53 | normalizeLinkText (url: string): string; 54 | validateLink (url: string): boolean; 55 | } 56 | 57 | interface Options { 58 | html?: boolean; 59 | xhtmlOut?: boolean; 60 | breaks?: boolean; 61 | langPrefix?: string; 62 | linkify?: boolean; 63 | typographer?: boolean; 64 | quotes?: string; 65 | highlight?: (str: string, lang: string) => void; 66 | } 67 | 68 | interface State { 69 | src: string; 70 | level: number; 71 | tokens: Token[]; 72 | delimiters: any[]; 73 | push (type: string, tag: string, nesting: number): Token; 74 | } 75 | 76 | interface InlineDelimiter { 77 | can_open: true; 78 | can_close: false; 79 | length: number; 80 | } 81 | 82 | interface StateInline extends State { 83 | pos: number; 84 | posMax: number; 85 | pending: string; 86 | scanDelims (start: number, canSplitWord: boolean): InlineDelimiter; 87 | } 88 | 89 | interface StateBlock extends State { 90 | line: number; 91 | bMarks: number[]; 92 | eMarks: number[]; 93 | tShift: number[]; 94 | sCount: number[]; 95 | blkIndent: number; 96 | getLines (begin?: number, end?: number, indent?: number, keepLastLF?: boolean): string[]; 97 | } 98 | 99 | interface Renderer { 100 | rules: { [name: string]: TokenRender }; 101 | render (tokens: Token[], options: any, env: any): string; 102 | renderAttrs (token: Token): string; 103 | renderInline (tokens: Token[], options: any, env: any): string; 104 | renderToken (tokens: Token[], idx: number, options: any): string; 105 | } 106 | 107 | interface Token { 108 | attrGet: (name: string) => string | null; 109 | attrIndex: (name: string) => number; 110 | attrJoin: (name: string, value: string) => void; 111 | attrPush: (attrData: string[]) => void; 112 | attrSet: (name: string, value: string) => void; 113 | attrs: string[][]; 114 | block: boolean; 115 | children: Token[]; 116 | content: string; 117 | hidden: boolean; 118 | info: string; 119 | level: number; 120 | map: number[]; 121 | markup: string; 122 | meta: any; 123 | nesting: number; 124 | tag: string; 125 | type: string; 126 | } 127 | 128 | type TokenRender = (tokens: Token[], index: number, options: any, env: any, self: Renderer) => void; 129 | 130 | interface Rule { 131 | (state: any): void; 132 | } 133 | 134 | interface Ruler { 135 | after (afterName: string, ruleName: string, rule: Function, options?: any): void; 136 | at (name: string, rule: Function, options?: any): void; 137 | before (beforeName: string, ruleName: string, rule: Function, options?: any): void; 138 | disable (rules: string | string[], ignoreInvalid?: boolean): string[]; 139 | enable (rules: string | string[], ignoreInvalid?: boolean): string[]; 140 | enableOnly (rule: string, ignoreInvalid?: boolean): void; 141 | getRules (chain: string): Rule[]; 142 | push (ruleName: string, rule: Function, options?: any): void; 143 | } 144 | 145 | interface ParserBlock { 146 | ruler: Ruler; 147 | parse (src: string, md: MarkdownIt, env: any, outTokens: Token[]): void; 148 | } 149 | 150 | interface Core { 151 | ruler: Ruler; 152 | process (state: any): void; 153 | } 154 | 155 | interface ParserInline { 156 | ruler: Ruler; 157 | ruler2: Ruler; 158 | parse (src: string, md: MarkdownIt, env: any, outTokens: Token[]): void; 159 | } 160 | } 161 | 162 | declare namespace LinkifyIt { 163 | interface Options { 164 | fuzzyLink?: boolean; 165 | fuzzyEmail?: boolean; 166 | fuzzyIP?: boolean; 167 | } 168 | 169 | interface MatchDescription { 170 | __schema__: string; 171 | __index__: number; 172 | __lastIndex__: number; 173 | __raw__: string; 174 | __text__: string; 175 | __url__: string; 176 | } 177 | 178 | interface LinkifyIt { 179 | tlds (lang: string, linkified: boolean): this; 180 | add (schema: string, definition: string | RegExp | object): this; 181 | set (options: Options): this; 182 | test (text: string): boolean; 183 | pretest (text: string): boolean; 184 | match (text: string): MatchDescription; 185 | } 186 | } 187 | --------------------------------------------------------------------------------