diff --git a/app/components/ClickablePadding.js b/app/components/ClickablePadding.js index 1340338e..30ac2cf3 100644 --- a/app/components/ClickablePadding.js +++ b/app/components/ClickablePadding.js @@ -2,7 +2,7 @@ import styled from 'styled-components'; const ClickablePadding = styled.div` - min-height: 6em; + min-height: 10em; cursor: ${({ onClick }) => (onClick ? 'text' : 'default')}; ${({ grow }) => grow && `flex-grow: 100;`}; `; diff --git a/app/components/DocumentPreview/DocumentPreview.js b/app/components/DocumentPreview/DocumentPreview.js index 2b498ffc..e80bf4be 100644 --- a/app/components/DocumentPreview/DocumentPreview.js +++ b/app/components/DocumentPreview/DocumentPreview.js @@ -2,14 +2,13 @@ import * as React from 'react'; import { observer } from 'mobx-react'; import { Link } from 'react-router-dom'; -import Document from 'models/Document'; +import { StarredIcon } from 'outline-icons'; import styled, { withTheme } from 'styled-components'; -import { darken } from 'polished'; import Flex from 'shared/components/Flex'; import Highlight from 'components/Highlight'; -import { StarredIcon } from 'outline-icons'; -import PublishingInfo from './components/PublishingInfo'; +import PublishingInfo from 'components/PublishingInfo'; import DocumentMenu from 'menus/DocumentMenu'; +import Document from 'models/Document'; type Props = { document: Document, @@ -45,8 +44,8 @@ const StyledDocumentMenu = styled(DocumentMenu)` const DocumentLink = styled(Link)` display: block; - margin: 0 -16px; - padding: 10px 16px; + margin: 8px -8px; + padding: 6px 8px; border-radius: 8px; border: 2px solid transparent; max-height: 50vh; @@ -62,7 +61,6 @@ const DocumentLink = styled(Link)` &:active, &:focus { background: ${props => props.theme.listItemHoverBackground}; - border: 2px solid ${props => props.theme.listItemHoverBorder}; outline: none; ${StyledStar}, ${StyledDocumentMenu} { @@ -73,10 +71,6 @@ const DocumentLink = styled(Link)` } } } - - &:focus { - border: 2px solid ${props => darken(0.5, props.theme.listItemHoverBorder)}; - } `; const Heading = styled.h3` diff --git a/app/components/Editor/Editor.js b/app/components/Editor/Editor.js index dd9a1ad9..12ac5024 100644 --- a/app/components/Editor/Editor.js +++ b/app/components/Editor/Editor.js @@ -3,8 +3,10 @@ import * as React from 'react'; import { Redirect } from 'react-router-dom'; import { observable } from 'mobx'; import { observer } from 'mobx-react'; -import { withTheme } from 'styled-components'; +import { lighten } from 'polished'; +import styled, { withTheme } from 'styled-components'; import RichMarkdownEditor from 'rich-markdown-editor'; +import Placeholder from 'rich-markdown-editor/lib/components/Placeholder'; import { uploadFile } from 'utils/uploadFile'; import isInternalUrl from 'utils/isInternalUrl'; import Tooltip from 'components/Tooltip'; @@ -79,7 +81,7 @@ class Editor extends React.Component { if (this.redirectTo) return ; return ( - { } } +const StyledEditor = styled(RichMarkdownEditor)` + justify-content: start; + + > div { + transition: ${props => props.theme.backgroundTransition}; + } + + p { + ${Placeholder} { + visibility: hidden; + } + } + p:nth-child(2):last-child { + ${Placeholder} { + visibility: visible; + } + } + + p { + a { + color: ${props => props.theme.link}; + border-bottom: 1px solid ${props => lighten(0.5, props.theme.link)}; + font-weight: 500; + + &:hover { + border-bottom: 1px solid ${props => props.theme.link}; + text-decoration: none; + } + } + } +`; + const EditorTooltip = props => ; export default withTheme( diff --git a/app/components/PathToDocument.js b/app/components/PathToDocument.js index e67cd5ad..34d7b1fe 100644 --- a/app/components/PathToDocument.js +++ b/app/components/PathToDocument.js @@ -1,7 +1,6 @@ // @flow import * as React from 'react'; import { observer } from 'mobx-react'; -import { darken } from 'polished'; import styled from 'styled-components'; import { GoToIcon, CollectionIcon, PrivateCollectionIcon } from 'outline-icons'; import Flex from 'shared/components/Flex'; @@ -92,13 +91,8 @@ const ResultWrapperLink = styled(ResultWrapper.withComponent('a'))` &:active, &:focus { background: ${props => props.theme.listItemHoverBackground}; - border: 2px solid ${props => props.theme.listItemHoverBorder}; outline: none; } - - &:focus { - border: 2px solid ${props => darken(0.5, props.theme.listItemHoverBorder)}; - } `; export default PathToDocument; diff --git a/app/components/DocumentPreview/components/PublishingInfo.js b/app/components/PublishingInfo.js similarity index 100% rename from app/components/DocumentPreview/components/PublishingInfo.js rename to app/components/PublishingInfo.js diff --git a/app/components/Tooltip.js b/app/components/Tooltip.js index 3dfc9ebd..c8592ddd 100644 --- a/app/components/Tooltip.js +++ b/app/components/Tooltip.js @@ -201,13 +201,21 @@ type Props = { offset?: number, }; -const Tooltip = function({ offset = 0, ...rest }: Props) { - return ( - - - - - ); -}; +class Tooltip extends React.Component { + shouldComponentUpdate() { + return false; + } + + render() { + const { offset = 0, ...rest } = this.props; + + return ( + + + + + ); + } +} export default Tooltip; diff --git a/app/scenes/Document/Document.js b/app/scenes/Document/Document.js index 7f033959..735a7385 100644 --- a/app/scenes/Document/Document.js +++ b/app/scenes/Document/Document.js @@ -22,6 +22,7 @@ import { emojiToUrl } from 'utils/emoji'; import Header from './components/Header'; import DocumentMove from './components/DocumentMove'; import Branding from './components/Branding'; +import Backlinks from './components/Backlinks'; import ErrorBoundary from 'components/ErrorBoundary'; import LoadingPlaceholder from 'components/LoadingPlaceholder'; import LoadingIndicator from 'components/LoadingIndicator'; @@ -415,6 +416,12 @@ class DocumentScene extends React.Component { ui={this.props.ui} schema={schema} /> + {!this.isEditing && ( + + )} diff --git a/app/scenes/Document/components/Backlink.js b/app/scenes/Document/components/Backlink.js new file mode 100644 index 00000000..b44b56cb --- /dev/null +++ b/app/scenes/Document/components/Backlink.js @@ -0,0 +1,68 @@ +// @flow +import * as React from 'react'; +import { observer } from 'mobx-react'; +import { Link } from 'react-router-dom'; +import styled from 'styled-components'; +import PublishingInfo from 'components/PublishingInfo'; +import Document from 'models/Document'; + +type Props = { + document: Document, + anchor: string, + showCollection?: boolean, + ref?: *, +}; + +const DocumentLink = styled(Link)` + display: block; + margin: 0 -8px; + padding: 6px 8px; + border-radius: 8px; + border: 2px solid transparent; + max-height: 50vh; + min-width: 100%; + overflow: hidden; + position: relative; + + &:hover, + &:active, + &:focus { + background: ${props => props.theme.listItemHoverBackground}; + outline: none; + } +`; + +const Title = styled.h3` + max-width: 90%; + overflow: hidden; + text-overflow: ellipsis; + font-size: 14px; + margin-top: 0; + margin-bottom: 0.25em; + white-space: nowrap; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, + Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif; +`; + +@observer +class Backlink extends React.Component { + render() { + const { document, showCollection, anchor, ...rest } = this.props; + + return ( + + {document.title} + + + ); + } +} + +export default Backlink; diff --git a/app/scenes/Document/components/Backlinks.js b/app/scenes/Document/components/Backlinks.js new file mode 100644 index 00000000..9781c84e --- /dev/null +++ b/app/scenes/Document/components/Backlinks.js @@ -0,0 +1,46 @@ +// @flow +import * as React from 'react'; +import { observer } from 'mobx-react'; +import Fade from 'components/Fade'; +import Subheading from 'components/Subheading'; +import DocumentsStore from 'stores/DocumentsStore'; +import Document from 'models/Document'; +import Backlink from './Backlink'; + +type Props = { + document: Document, + documents: DocumentsStore, +}; + +@observer +class Backlinks extends React.Component { + componentDidMount() { + this.props.documents.fetchBacklinks(this.props.document.id); + } + + render() { + const { documents, document } = this.props; + const backlinks = documents.getBacklinedDocuments(document.id); + const showBacklinks = !!backlinks.length; + + return ( + showBacklinks && ( + + Referenced By + {backlinks.map(backlinkedDocument => ( + + ))} + + ) + ); + } +} + +export default Backlinks; diff --git a/app/scenes/Document/components/Editor.js b/app/scenes/Document/components/Editor.js index ef1771b0..ab9f1a5c 100644 --- a/app/scenes/Document/components/Editor.js +++ b/app/scenes/Document/components/Editor.js @@ -1,8 +1,6 @@ // @flow import * as React from 'react'; -import styled from 'styled-components'; import Editor from 'components/Editor'; -import Placeholder from 'rich-markdown-editor/lib/components/Placeholder'; import ClickablePadding from 'components/ClickablePadding'; import plugins from './plugins'; @@ -33,7 +31,7 @@ class DocumentEditor extends React.Component { return ( - (this.editor = ref)} plugins={plugins} {...this.props} @@ -47,23 +45,4 @@ class DocumentEditor extends React.Component { } } -const StyledEditor = styled(Editor)` - justify-content: start; - - > div { - transition: ${props => props.theme.backgroundTransition}; - } - - p { - ${Placeholder} { - visibility: hidden; - } - } - p:nth-child(2):last-child { - ${Placeholder} { - visibility: visible; - } - } -`; - export default DocumentEditor; diff --git a/app/stores/DocumentsStore.js b/app/stores/DocumentsStore.js index 34c3fdf8..a0d6bbc4 100644 --- a/app/stores/DocumentsStore.js +++ b/app/stores/DocumentsStore.js @@ -24,6 +24,7 @@ export default class DocumentsStore extends BaseStore { @observable recentlyViewedIds: string[] = []; @observable searchCache: Map = new Map(); @observable starredIds: Map = new Map(); + @observable backlinks: Map = new Map(); constructor(rootStore: RootStore) { super(rootStore, Document); @@ -140,6 +141,28 @@ export default class DocumentsStore extends BaseStore { : undefined; } + @action + fetchBacklinks = async (documentId: string): Promise => { + const res = await client.post(`/documents.list`, { + backlinkDocumentId: documentId, + }); + invariant(res && res.data, 'Document list not available'); + const { data } = res; + runInAction('DocumentsStore#fetchBacklinks', () => { + data.forEach(this.add); + this.backlinks.set(documentId, data.map(doc => doc.id)); + }); + }; + + getBacklinedDocuments(documentId: string): Document[] { + const documentIds = this.backlinks.get(documentId) || []; + return orderBy( + compact(documentIds.map(id => this.data.get(id))), + 'updatedAt', + 'desc' + ); + } + @action fetchNamedPage = async ( request: string = 'list', diff --git a/server/api/documents.js b/server/api/documents.js index 6332bece..e5dc986f 100644 --- a/server/api/documents.js +++ b/server/api/documents.js @@ -9,10 +9,19 @@ import { presentCollection, presentRevision, } from '../presenters'; -import { Document, Collection, Share, Star, View, Revision } from '../models'; +import { + Document, + Collection, + Share, + Star, + View, + Revision, + Backlink, +} from '../models'; import { InvalidRequestError } from '../errors'; import events from '../events'; import policy from '../policies'; +import { sequelize } from '../sequelize'; const Op = Sequelize.Op; const { authorize, cannot } = policy; @@ -22,6 +31,7 @@ router.post('documents.list', auth(), pagination(), async ctx => { const { sort = 'updatedAt' } = ctx.body; const collectionId = ctx.body.collection; const createdById = ctx.body.user; + const backlinkDocumentId = ctx.body.backlinkDocumentId; let direction = ctx.body.direction; if (direction !== 'ASC') direction = 'DESC'; @@ -50,6 +60,20 @@ router.post('documents.list', auth(), pagination(), async ctx => { where = { ...where, collectionId: collectionIds }; } + if (backlinkDocumentId) { + const backlinks = await Backlink.findAll({ + attributes: ['reverseDocumentId'], + where: { + documentId: backlinkDocumentId, + }, + }); + + where = { + ...where, + id: backlinks.map(backlink => backlink.reverseDocumentId), + }; + } + // add the users starred state to the response by default const starredScope = { method: ['withStarred', user.id] }; const documents = await Document.scope('defaultScope', starredScope).findAll({ @@ -620,7 +644,7 @@ router.post('documents.update', auth(), async ctx => { // Update document if (title) document.title = title; - //append to document + if (append) { document.text += text; } else if (text) { @@ -628,28 +652,40 @@ router.post('documents.update', auth(), async ctx => { } document.lastModifiedById = user.id; - if (publish) { - await document.publish(); + let transaction; + try { + transaction = await sequelize.transaction(); - events.add({ - name: 'documents.publish', - modelId: document.id, - collectionId: document.collectionId, - teamId: document.teamId, - actorId: user.id, - }); - } else { - await document.save({ autosave }); + if (publish) { + await document.publish({ transaction }); + await transaction.commit(); - events.add({ - name: 'documents.update', - modelId: document.id, - collectionId: document.collectionId, - teamId: document.teamId, - actorId: user.id, - autosave, - done, - }); + events.add({ + name: 'documents.publish', + modelId: document.id, + collectionId: document.collectionId, + teamId: document.teamId, + actorId: user.id, + }); + } else { + await document.save({ autosave, transaction }); + await transaction.commit(); + + events.add({ + name: 'documents.update', + modelId: document.id, + collectionId: document.collectionId, + teamId: document.teamId, + actorId: user.id, + autosave, + done, + }); + } + } catch (err) { + if (transaction) { + await transaction.rollback(); + } + throw err; } ctx.body = { diff --git a/server/api/documents.test.js b/server/api/documents.test.js index 2d5374c7..13a17079 100644 --- a/server/api/documents.test.js +++ b/server/api/documents.test.js @@ -1,7 +1,7 @@ /* eslint-disable flowtype/require-valid-file-annotation */ import TestServer from 'fetch-test-server'; import app from '../app'; -import { Document, View, Star, Revision } from '../models'; +import { Document, View, Star, Revision, Backlink } from '../models'; import { flushdb, seed } from '../test/support'; import { buildShare, @@ -252,6 +252,31 @@ describe('#documents.list', async () => { expect(body.data.length).toEqual(1); }); + it('should return backlinks', async () => { + const { user, document } = await seed(); + const anotherDoc = await buildDocument({ + title: 'another document', + text: 'random text', + userId: user.id, + teamId: user.teamId, + }); + + await Backlink.create({ + reverseDocumentId: anotherDoc.id, + documentId: document.id, + userId: user.id, + }); + + const res = await server.post('/api/documents.list', { + body: { token: user.getJwtToken(), backlinkDocumentId: document.id }, + }); + const body = await res.json(); + + expect(res.status).toEqual(200); + expect(body.data.length).toEqual(1); + expect(body.data[0].id).toEqual(anotherDoc.id); + }); + it('should require authentication', async () => { const res = await server.post('/api/documents.list'); const body = await res.json(); diff --git a/server/commands/userInviter.js b/server/commands/userInviter.js index 0e166c5e..e19212d9 100644 --- a/server/commands/userInviter.js +++ b/server/commands/userInviter.js @@ -6,7 +6,7 @@ import mailer from '../mailer'; type Invite = { name: string, email: string }; -export default async function documentMover({ +export default async function userInviter({ user, invites, }: { diff --git a/server/migrations/20190706213213-backlinks.js b/server/migrations/20190706213213-backlinks.js new file mode 100644 index 00000000..8da3cb8d --- /dev/null +++ b/server/migrations/20190706213213-backlinks.js @@ -0,0 +1,46 @@ +module.exports = { + up: async (queryInterface, Sequelize) => { + await queryInterface.createTable('backlinks', { + id: { + type: Sequelize.UUID, + allowNull: false, + primaryKey: true, + }, + userId: { + type: Sequelize.UUID, + allowNull: false, + references: { + model: 'users', + }, + }, + documentId: { + type: Sequelize.UUID, + allowNull: false, + references: { + model: 'documents', + }, + }, + reverseDocumentId: { + type: Sequelize.UUID, + allowNull: false, + references: { + model: 'documents', + }, + }, + createdAt: { + type: Sequelize.DATE, + allowNull: false, + }, + updatedAt: { + type: Sequelize.DATE, + allowNull: false, + }, + }); + await queryInterface.addIndex('backlinks', ['documentId']); + }, + + down: async (queryInterface, Sequelize) => { + await queryInterface.dropTable('backlinks'); + await queryInterface.removeIndex('backlinks', ['documentId']); + }, +}; diff --git a/server/models/Backlink.js b/server/models/Backlink.js new file mode 100644 index 00000000..dfc60077 --- /dev/null +++ b/server/models/Backlink.js @@ -0,0 +1,27 @@ +// @flow +import { DataTypes, sequelize } from '../sequelize'; + +const Backlink = sequelize.define('backlink', { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, +}); + +Backlink.associate = models => { + Backlink.belongsTo(models.Document, { + as: 'document', + foreignKey: 'documentId', + }); + Backlink.belongsTo(models.Document, { + as: 'reverseDocument', + foreignKey: 'reverseDocumentId', + }); + Backlink.belongsTo(models.User, { + as: 'user', + foreignKey: 'userId', + }); +}; + +export default Backlink; diff --git a/server/models/Document.js b/server/models/Document.js index 239239c3..f98462da 100644 --- a/server/models/Document.js +++ b/server/models/Document.js @@ -32,12 +32,17 @@ const createRevision = (doc, options = {}) => { // we don't create revisions if identical to previous if (doc.text === doc.previous('text')) return; - return Revision.create({ - title: doc.title, - text: doc.text, - userId: doc.lastModifiedById, - documentId: doc.id, - }); + return Revision.create( + { + title: doc.title, + text: doc.text, + userId: doc.lastModifiedById, + documentId: doc.id, + }, + { + transaction: options.transaction, + } + ); }; const createUrlId = doc => { @@ -141,6 +146,9 @@ Document.associate = models => { as: 'revisions', onDelete: 'cascade', }); + Document.hasMany(models.Backlink, { + as: 'backlinks', + }); Document.hasMany(models.Star, { as: 'starred', }); @@ -363,16 +371,16 @@ Document.prototype.archiveWithChildren = async function(userId, options) { return this.save(options); }; -Document.prototype.publish = async function() { - if (this.publishedAt) return this.save(); +Document.prototype.publish = async function(options) { + if (this.publishedAt) return this.save(options); const collection = await Collection.findByPk(this.collectionId); - if (collection.type !== 'atlas') return this.save(); + if (collection.type !== 'atlas') return this.save(options); await collection.addDocumentToStructure(this); this.publishedAt = new Date(); - await this.save(); + await this.save(options); this.collection = collection; return this; diff --git a/server/models/index.js b/server/models/index.js index e599c1c6..caeda47c 100644 --- a/server/models/index.js +++ b/server/models/index.js @@ -1,6 +1,7 @@ // @flow import ApiKey from './ApiKey'; import Authentication from './Authentication'; +import Backlink from './Backlink'; import Collection from './Collection'; import CollectionUser from './CollectionUser'; import Document from './Document'; @@ -18,6 +19,7 @@ import View from './View'; const models = { ApiKey, Authentication, + Backlink, Collection, CollectionUser, Document, @@ -43,6 +45,7 @@ Object.keys(models).forEach(modelName => { export { ApiKey, Authentication, + Backlink, Collection, CollectionUser, Document, diff --git a/server/pages/developers/Api.js b/server/pages/developers/Api.js index dcf36675..9f84b551 100644 --- a/server/pages/developers/Api.js +++ b/server/pages/developers/Api.js @@ -219,6 +219,11 @@ export default function Api() { id="collection" description="Collection ID to filter by" /> + + diff --git a/server/services/backlinks.js b/server/services/backlinks.js new file mode 100644 index 00000000..5fdc1806 --- /dev/null +++ b/server/services/backlinks.js @@ -0,0 +1,97 @@ +// @flow +import { difference } from 'lodash'; +import type { DocumentEvent } from '../events'; +import { Document, Revision, Backlink } from '../models'; +import parseDocumentIds from '../../shared/utils/parseDocumentIds'; + +export default class Backlinks { + async on(event: DocumentEvent) { + switch (event.name) { + case 'documents.publish': { + const document = await Document.findByPk(event.modelId); + const linkIds = parseDocumentIds(document.text); + + await Promise.all( + linkIds.map(async linkId => { + const linkedDocument = await Document.findByPk(linkId); + if (linkedDocument.id === event.modelId) return; + + await Backlink.findOrCreate({ + where: { + documentId: linkedDocument.id, + reverseDocumentId: event.modelId, + }, + defaults: { + userId: document.lastModifiedById, + }, + }); + }) + ); + + break; + } + case 'documents.update': { + // no-op for now + if (event.autosave) return; + + // no-op for drafts + const document = await Document.findByPk(event.modelId); + if (!document.publishedAt) return; + + const [currentRevision, previsionRevision] = await Revision.findAll({ + where: { documentId: event.modelId }, + order: [['createdAt', 'desc']], + limit: 2, + }); + const previousLinkIds = parseDocumentIds(previsionRevision.text); + const currentLinkIds = parseDocumentIds(currentRevision.text); + const addedLinkIds = difference(currentLinkIds, previousLinkIds); + const removedLinkIds = difference(previousLinkIds, currentLinkIds); + + await Promise.all( + addedLinkIds.map(async linkId => { + const linkedDocument = await Document.findByPk(linkId); + if (linkedDocument.id === event.modelId) return; + + await Backlink.findOrCreate({ + where: { + documentId: linkedDocument.id, + reverseDocumentId: event.modelId, + }, + defaults: { + userId: currentRevision.userId, + }, + }); + }) + ); + + await Promise.all( + removedLinkIds.map(async linkId => { + const document = await Document.findByPk(linkId); + await Backlink.destroy({ + where: { + documentId: document.id, + reverseDocumentId: event.modelId, + }, + }); + }) + ); + break; + } + case 'documents.delete': { + await Backlink.destroy({ + where: { + reverseDocumentId: event.modelId, + }, + }); + await Backlink.destroy({ + where: { + documentId: event.modelId, + }, + }); + break; + } + default: + } + } +} diff --git a/shared/styles/theme.js b/shared/styles/theme.js index 793c18b1..eb6a61d8 100644 --- a/shared/styles/theme.js +++ b/shared/styles/theme.js @@ -22,6 +22,7 @@ const colors = { black50: 'rgba(0, 0, 0, 0.50)', primary: '#1AB6FF', yellow: '#FBCA04', + warmGrey: '#EDF2F7', danger: '#D0021B', warning: '#f08a24', @@ -44,7 +45,6 @@ export const base = { fontFamily: "-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Oxygen, Ubuntu,Cantarell,'Open Sans','Helvetica Neue',sans-serif", fontWeight: 400, - link: colors.primary, backgroundTransition: 'background 100ms ease-in-out', zIndex: 100, }; @@ -53,12 +53,13 @@ export const light = { ...base, background: colors.white, + link: colors.almostBlack, text: colors.almostBlack, textSecondary: colors.slateDark, textTertiary: colors.slate, placeholder: '#B1BECC', - sidebarBackground: 'rgb(244, 247, 250)', + sidebarBackground: colors.warmGrey, sidebarItemBackground: colors.black05, sidebarText: 'rgb(78, 92, 110)', @@ -69,8 +70,7 @@ export const light = { inputBorder: colors.slateLight, inputBorderFocused: colors.slate, - listItemHoverBackground: colors.smoke, - listItemHoverBorder: colors.smokeDark, + listItemHoverBackground: colors.warmGrey, toolbarBackground: colors.lightBlack, toolbarInput: colors.white10, @@ -104,6 +104,7 @@ export const dark = { ...base, background: colors.almostBlack, + link: colors.almostWhite, text: colors.almostWhite, textSecondary: lighten(0.2, colors.slate), textTertiary: colors.slate, @@ -121,7 +122,6 @@ export const dark = { inputBorderFocused: colors.slate, listItemHoverBackground: colors.black50, - listItemHoverBorder: colors.black50, toolbarBackground: colors.white, toolbarInput: colors.black10, diff --git a/shared/utils/parseDocumentIds.js b/shared/utils/parseDocumentIds.js new file mode 100644 index 00000000..7dc466ac --- /dev/null +++ b/shared/utils/parseDocumentIds.js @@ -0,0 +1,29 @@ +// @flow +import MarkdownSerializer from 'slate-md-serializer'; +const Markdown = new MarkdownSerializer(); + +export default function parseDocumentIds(text: string) { + const value = Markdown.deserialize(text); + let links = []; + + function findLinks(node) { + if (node.type === 'link') { + const href = node.data.get('href'); + + if (href.startsWith('/doc')) { + const tokens = href.replace(/\/$/, '').split('/'); + const lastToken = tokens[tokens.length - 1]; + links.push(lastToken); + } + } + + if (!node.nodes) { + return; + } + + node.nodes.forEach(findLinks); + } + + findLinks(value.document); + return links; +} diff --git a/shared/utils/parseDocumentIds.test.js b/shared/utils/parseDocumentIds.test.js new file mode 100644 index 00000000..37c970ec --- /dev/null +++ b/shared/utils/parseDocumentIds.test.js @@ -0,0 +1,20 @@ +/* eslint-disable flowtype/require-valid-file-annotation */ +import parseDocumentIds from './parseDocumentIds'; + +it('should return an array of document ids', () => { + expect(parseDocumentIds(`# Header`).length).toBe(0); + expect( + parseDocumentIds(`# Header + + [title](/doc/test-456733) + `)[0] + ).toBe('test-456733'); +}); + +it('should not return non document links', () => { + expect(parseDocumentIds(`[title](http://www.google.com)`).length).toBe(0); +}); + +it('should not return non document relative links', () => { + expect(parseDocumentIds(`[title](/developers)`).length).toBe(0); +});