1
0
Fork 0
mirror of https://github.com/codex-team/codex.docs.git synced 2025-07-24 15:49:42 +02:00

Beautiful urls (#18)

* Added uri property to Page model

* Added aliases collection

* Added routing form aliases

* Fixed redirect after page creation

* Added abiltity to support few pages with same title

* Added ability to change uri manually

* Changed hash function

* Changed uri parsing

* Removed pages controller promise

* Modified page's tests

* Added tests for alias model

* Added tests for aliases

* Escaping special characters

* Added missed files

* Fixed bugs related to translation

* Fixed parent page link

* Added server validation for uri

* Changed css properties order

* Made uri property of page be optional

* Prevented alias creation from empty uri

* Moved alias types to model
This commit is contained in:
DorofeevMark 2019-01-25 02:23:00 +03:00 committed by Peter Savchenko
parent ed69336481
commit d872e78339
28 changed files with 807 additions and 45 deletions

View file

@ -0,0 +1,26 @@
const Alias = require('../models/alias');
/**
* @class Aliases
* @classdesc Aliases controller
*/
class Aliases {
/**
* @static
* Find and return entity with given alias
*
* @param {string} aliasName - alias name of entity
* @returns {Promise<Alias>}
*/
static async get(aliasName) {
const alias = await Alias.get(aliasName);
if (!alias.id) {
throw new Error('Entity with given alias does not exist');
}
return alias;
}
}
module.exports = Aliases;

View file

@ -1,4 +1,5 @@
const Model = require('../models/page');
const Alias = require('../models/alias');
/**
* @class Pages
@ -85,7 +86,18 @@ class Pages {
const page = new Model(data);
return page.save();
const insertedPage = await page.save();
if (insertedPage.uri) {
const alias = new Alias({
id: insertedPage._id,
type: Alias.types.PAGE
}, insertedPage.uri);
alias.save();
}
return insertedPage;
} catch (validationError) {
throw new Error(validationError);
}
@ -132,13 +144,33 @@ class Pages {
*/
static async update(id, data) {
const page = await Model.get(id);
const previousUri = page.uri;
if (!page._id) {
throw new Error('Page with given id does not exist');
}
if (data.uri && !data.uri.match(/^[a-z0-9'-]+$/i)) {
throw new Error('Uri has unexpected characters');
}
page.data = data;
return page.save();
const updatedPage = await page.save();
if (updatedPage.uri !== previousUri) {
if (updatedPage.uri) {
const alias = new Alias({
id: updatedPage._id,
type: Alias.types.PAGE
}, updatedPage.uri);
alias.save();
}
Alias.markAsDeprecated(previousUri);
}
return updatedPage;
}
/**

View file

@ -29,6 +29,7 @@ export default class Writing {
saveButton: null,
parentIdSelector: null,
putAboveIdSelector: null,
uriInput: null
};
}
@ -63,6 +64,7 @@ export default class Writing {
});
this.nodes.parentIdSelector = moduleEl.querySelector('[name="parent"]');
this.nodes.putAboveIdSelector = moduleEl.querySelector('[name="above"]');
this.nodes.uriInput = moduleEl.querySelector('[name="uri-input"]');
};
/**
@ -86,6 +88,15 @@ export default class Writing {
const editorData = await this.editor.save();
const firstBlock = editorData.blocks.length ? editorData.blocks[0] : null;
const title = firstBlock && firstBlock.type === 'header' ? firstBlock.data.text : null;
let uri = '';
if (this.nodes.uriInput && this.nodes.uriInput.value) {
if (this.nodes.uriInput.value.match(/^[a-z0-9'-]+$/i)) {
uri = this.nodes.uriInput.value;
} else {
throw new Error('Uri has unexpected characters');
}
}
if (!title) {
throw new Error('Entry should start with Header');
@ -100,6 +111,7 @@ export default class Writing {
return {
parent: this.nodes.parentIdSelector.value,
putAbovePageId: putAbovePageId,
uri: uri,
body: editorData
};
}
@ -124,7 +136,7 @@ export default class Writing {
response = await response.json();
if (response.success) {
document.location = '/page/' + response.result._id;
window.location.pathname = response.result.uri ? response.result.uri : '/page/' + response.result._id;
} else {
alert(response.error);
console.log('Validation failed:', response.error);

View file

@ -1,13 +1,13 @@
.docs-header {
font-size: 15.8px;
border-bottom: 1px solid var(--color-line-gray);
line-height: 50px;
display: flex;
padding: 0 var(--layout-padding-horisontal);
border-bottom: 1px solid var(--color-line-gray);
font-size: 15.8px;
line-height: 50px;
a {
text-decoration: none;
display: inline-block;
text-decoration: none;
}
&__logo {

View file

@ -66,8 +66,8 @@
*/
.block-code {
padding: 20px !important;
font-size: 13px;
border-radius: 3px;
font-size: 13px;
border: 1px solid var(--color-line-gray);
font-family: Menlo,Monaco,Consolas,Courier New,monospace;
line-height: 1.7em;

View file

@ -1,12 +1,12 @@
.writing-header {
display: flex;
padding: 15px 0;
margin-top: calc(-1 * var(--layout-padding-vertical));
position: sticky;
top: 0;
padding: 15px 0;
margin-top: calc(-1 * var(--layout-padding-vertical));
background: #fff;
z-index: 2;
box-shadow: 0 3px 10px #fff;
z-index: 2;
&__save {
@apply --button;
@ -22,3 +22,13 @@
}
}
}
.uri-input {
box-sizing: border-box;
width: 100%;
padding: 10px 12px;
border-radius: 3px;
border: 1px solid rgba(201, 201, 204, 0.48);
box-shadow: inset 0 1px 2px 0 rgba(35, 44, 72, 0.06);
outline: none;
}

View file

@ -3,6 +3,7 @@
--color-text-second: #7B7E89;
--color-line-gray: #E8E8EB;
--color-link-active: #388AE5;
--color-gray-border: rgba(var(--color-line-gray), 0.48);
/**
* Site layout sizes
@ -14,10 +15,10 @@
--button {
display: inline-block;
padding: 9px 15px;
border-radius: 3px;
background: var(--color-link-active);
color: #fff;
border-radius: 3px;
padding: 9px 15px;
font-size: 14px;
line-height: 1em;
text-decoration: none;

129
src/models/alias.js Normal file
View file

@ -0,0 +1,129 @@
const {aliases: aliasesDb} = require('../utils/database/index');
const binaryMD5 = require('../utils/crypto');
/**
* @typedef {Object} AliasData
* @property {string} _id - alias id
* @property {string} hash - alias binary hash
* @property {string} type - entity type
* @property {boolean} deprecated - indicate if alias deprecated
* @property {string} id - entity id
*
*/
/**
* @class Alias
* @property {string} _id - alias id
* @property {string} hash - alias binary hash
* @property {string} type - entity type
* @property {boolean} deprecated - indicate if alias deprecated
* @property {string} id - entity title
*/
class Alias {
/**
* Return Alias types
*
* @returns {Object}
*/
static get types() {
return {
PAGE: 'page'
};
};
/**
* Find and return alias with given alias
* @param {string} aliasName - alias of entity
* @returns {Promise<Alias>}
*/
static async get(aliasName) {
const hash = binaryMD5(aliasName);
let data = await aliasesDb.findOne({hash: hash, deprecated: false});
if (!data) {
data = await aliasesDb.findOne({hash: hash});
}
return new Alias(data);
}
/**
* @constructor
*
* @param {AliasData} data
* @param {string} aliasName - alias of entity
*/
constructor(data = {}, aliasName = '') {
if (data === null) {
data = {};
}
if (data._id) {
this._id = data._id;
}
if (aliasName) {
this.hash = binaryMD5(aliasName);
}
this.data = data;
}
/**
* Save or update alias data in the database
*
* @returns {Promise<Alias>}
*/
async save() {
if (!this._id) {
const insertedRow = await aliasesDb.insert(this.data);
this._id = insertedRow._id;
} else {
await aliasesDb.update({_id: this._id}, this.data);
}
return this;
}
/**
* Set AliasData object fields to internal model fields
*
* @param {AliasData} aliasData
*/
set data(aliasData) {
const {id, type, hash, deprecated} = aliasData;
this.id = id || this.id;
this.type = type || this.type;
this.hash = hash || this.hash;
this.deprecated = deprecated || false;
}
/**
* Return AliasData object
*
* @returns {AliasData}
*/
get data() {
return {
_id: this._id,
id: this.id,
type: this.type,
hash: this.hash,
deprecated: this.deprecated
};
}
/**
* Mark alias as deprecated
* @param {string} aliasName - alias of entity
* @returns {Promise<Alias>}
*/
static async markAsDeprecated(aliasName) {
const alias = await Alias.get(aliasName);
alias.deprecated = true;
return alias.save();
}
}
module.exports = Alias;

View file

@ -1,9 +1,11 @@
const {pages: db} = require('../utils/database/index');
const {pages: pagesDb} = require('../utils/database/index');
const translateString = require('../utils/translation');
/**
* @typedef {Object} PageData
* @property {string} _id - page id
* @property {string} title - page title
* @property {string} uri - page uri
* @property {*} body - page body
* @property {string} parent - id of parent page
*/
@ -14,6 +16,7 @@ const {pages: db} = require('../utils/database/index');
*
* @property {string} _id - page id
* @property {string} title - page title
* @property {string} uri - page uri
* @property {*} body - page body
* @property {string} _parent - id of parent page
*/
@ -24,7 +27,18 @@ class Page {
* @returns {Promise<Page>}
*/
static async get(_id) {
const data = await db.findOne({_id});
const data = await pagesDb.findOne({_id});
return new Page(data);
}
/**
* Find and return model of page with given uri
* @param {string} uri - page uri
* @returns {Promise<Page>}
*/
static async getByUri(uri) {
const data = await pagesDb.findOne({uri});
return new Page(data);
}
@ -36,7 +50,7 @@ class Page {
* @returns {Promise<Page[]>}
*/
static async getAll(query = {}) {
const docs = await db.find(query);
const docs = await pagesDb.find(query);
return Promise.all(docs.map(doc => new Page(doc)));
}
@ -64,10 +78,11 @@ class Page {
* @param {PageData} pageData
*/
set data(pageData) {
const {body, parent} = pageData;
const {body, parent, uri} = pageData;
this.body = body || this.body;
this.title = this.extractTitleFromBody();
this.uri = uri || '';
this._parent = parent || this._parent;
}
@ -80,6 +95,7 @@ class Page {
return {
_id: this._id,
title: this.title,
uri: this.uri,
body: this.body,
parent: this._parent
};
@ -95,6 +111,21 @@ class Page {
return headerBlock ? headerBlock.data.text : '';
}
/**
* Transform title for uri
* @return {string}
*/
transformTitleToUri() {
return translateString(this.title
.replace(/&nbsp;/g, ' ')
.replace(/[^a-zA-Z0-9А-Яа-яЁё ]/g, ' ')
.replace(/ +/g, ' ')
.trim()
.toLowerCase()
.split(' ')
.join('-'));
}
/**
* Link given page as parent
*
@ -110,7 +141,7 @@ class Page {
* @returns {Promise<Page>}
*/
get parent() {
return db.findOne({_id: this._parent})
return pagesDb.findOne({_id: this._parent})
.then(data => new Page(data));
}
@ -120,7 +151,7 @@ class Page {
* @returns {Promise<Page[]>}
*/
get children() {
return db.find({parent: this._id})
return pagesDb.find({parent: this._id})
.then(data => data.map(page => new Page(page)));
}
@ -130,12 +161,14 @@ class Page {
* @returns {Promise<Page>}
*/
async save() {
this.uri = await this.composeUri(this.uri);
if (!this._id) {
const insertedRow = await db.insert(this.data);
const insertedRow = await pagesDb.insert(this.data);
this._id = insertedRow._id;
} else {
await db.update({_id: this._id}, this.data);
await pagesDb.update({_id: this._id}, this.data);
}
return this;
@ -147,13 +180,37 @@ class Page {
* @returns {Promise<Page>}
*/
async destroy() {
await db.remove({_id: this._id});
await pagesDb.remove({_id: this._id});
delete this._id;
return this;
}
/**
* Find and return available uri
*
* @returns {Promise<string>}
*/
async composeUri(uri) {
let pageWithSameUriCount = 0;
if (!this._id) {
uri = this.transformTitleToUri();
}
if (uri) {
let pageWithSameUri = await Page.getByUri(uri);
while (pageWithSameUri._id && pageWithSameUri._id !== this._id) {
pageWithSameUriCount++;
pageWithSameUri = await Page.getByUri(uri + `-${pageWithSameUriCount}`);
}
}
return pageWithSameUriCount ? uri + `-${pageWithSameUriCount}` : uri;
}
/**
* Return readable page data
*

35
src/routes/aliases.js Normal file
View file

@ -0,0 +1,35 @@
const express = require('express');
const router = express.Router();
const Aliases = require('../controllers/aliases');
const Pages = require('../controllers/pages');
const Alias = require('../models/alias');
/**
* GET /*
*
* Return document with given alias
*/
router.get('*', async (req, res) => {
try {
const alias = await Aliases.get(req.originalUrl.slice(1)); // Cuts first '/' character
switch (alias.type) {
case Alias.types.PAGE: {
let page = await Pages.get(alias.id);
let pageParent = await page.parent;
res.render('pages/page', {
page, pageParent
});
}
}
} catch (err) {
res.status(400).json({
success: false,
error: err.message
});
}
});
module.exports = router;

View file

@ -80,7 +80,7 @@ router.post('/page/:id', multer.any(), async (req, res) => {
const {id} = req.params;
try {
const {title, body, parent, putAbovePageId} = req.body;
const {title, body, parent, putAbovePageId, uri} = req.body;
let page = await Pages.get(id);
if (page._parent !== parent) {
@ -91,7 +91,7 @@ router.post('/page/:id', multer.any(), async (req, res) => {
}
}
page = await Pages.update(id, {title, body, parent});
page = await Pages.update(id, {title, body, parent, uri});
res.json({
success: true,
result: page

View file

@ -3,6 +3,7 @@ const router = express.Router();
const home = require('./home');
const pages = require('./pages');
const aliases = require('./aliases');
const api = require('./api');
const pagesMiddleware = require('./middlewares/pages');
@ -10,5 +11,6 @@ const pagesMiddleware = require('./middlewares/pages');
router.use('/', pagesMiddleware, home);
router.use('/', pagesMiddleware, pages);
router.use('/api', api);
router.use('/', aliases);
module.exports = router;

12
src/utils/crypto.js Normal file
View file

@ -0,0 +1,12 @@
const crypto = require('crypto');
/**
* Create binary md5
* @param stringToHash - string to hash
* @returns {string} - binary hash of argument
*/
module.exports = function binaryMD5(stringToHash) {
return crypto.createHash('md5')
.update(stringToHash)
.digest('binary');
};

View file

@ -0,0 +1,6 @@
const Datastore = require('nedb');
const config = require('../../../config');
const db = new Datastore({filename: `./${config.database}/aliases.db`, autoload: true});
module.exports = db;

View file

@ -1,4 +1,5 @@
const pages = require('./pages');
const aliases = require('./aliases');
const pagesOrder = require('./pagesOrder');
/**
@ -144,5 +145,6 @@ class Database {
module.exports = {
class: Database,
pages: new Database(pages),
aliases: new Database(aliases),
pagesOrder: new Database(pagesOrder)
};

78
src/utils/translation.js Normal file
View file

@ -0,0 +1,78 @@
const translationTable = {
'а': 'a',
'б': 'b',
'в': 'v',
'г': 'g',
'д': 'd',
'е': 'e',
'ж': 'g',
'з': 'z',
'и': 'i',
'й': 'y',
'к': 'k',
'л': 'l',
'м': 'm',
'н': 'n',
'о': 'o',
'п': 'p',
'р': 'r',
'с': 's',
'т': 't',
'у': 'u',
'ф': 'f',
'ы': 'i',
'э': 'e',
'А': 'A',
'Б': 'B',
'В': 'V',
'Г': 'G',
'Д': 'D',
'Е': 'E',
'Ж': 'G',
'З': 'Z',
'И': 'I',
'Й': 'Y',
'К': 'K',
'Л': 'L',
'М': 'M',
'Н': 'N',
'О': 'O',
'П': 'P',
'Р': 'R',
'С': 'S',
'Т': 'T',
'У': 'U',
'Ф': 'F',
'Ы': 'I',
'Э': 'E',
'ё': 'yo',
'х': 'h',
'ц': 'ts',
'ч': 'ch',
'ш': 'sh',
'щ': 'shch',
'ъ': "''",
'ь': "'",
'ю': 'yu',
'я': 'ya',
'Ё': 'YO',
'Х': 'H',
'Ц': 'TS',
'Ч': 'CH',
'Ш': 'SH',
'Щ': 'SHCH',
'Ъ': "''",
'Ь': "'",
'Ю': 'YU',
'Я': 'YA'
};
/**
* Function to translate string
*
* @param string - string to translate
* @returns {string} - translated string
*/
module.exports = function translateString(string) {
return string.replace(/[А-яёЁ]/g, (char) => translationTable[char] || char);
};

View file

@ -1,14 +1,24 @@
<div class="docs-aside">
{% for firstLevelPage in menu %}
<section class="docs-aside__section">
<a class="docs-aside__section-title" href="/page/{{ firstLevelPage._id }}">
<a class="docs-aside__section-title"
{% if firstLevelPage.uri %}
href="/{{ firstLevelPage.uri }}"
{% else %}
href="/page/{{ firstLevelPage._id }}"
{% endif %}>
{{ firstLevelPage.title }}
</a>
{% if firstLevelPage.children is not empty %}
<ul class="docs-aside__section-list">
{% for child in firstLevelPage.children %}
<li>
<a href="/page/{{ child._id }}">
<a
{% if child.uri %}
href="/{{ child.uri }}"
{% else %}
href="/page/{{ child._id }}"
{% endif %}>
{{ child.title }}
</a>
</li>

View file

@ -11,7 +11,12 @@
</li>
{% for option in config.menu %}
<li>
<a href="{{option.uri}}">
<a
{% if child.uri %}
href="{{ option.uri }}"
{% else %}
href="/page/{{ option._id }}"
{% endif %}>
{{ option.title }}
</a>
</li>

View file

@ -21,7 +21,7 @@
{% set currentPageId = page._id %}
{% endif %}
<select name="parent">
<option value="0">Root</option>
<option value="0">Root</option>
{% for _page in pagesAvailable %}
{% if _page._id != currentPageId %}
<option value="{{ _page._id }}" {{ page is not empty and page._parent == _page._id ? 'selected' : ''}}>
@ -29,7 +29,7 @@
</option>
{% endif %}
{% endfor %}
</select>
</select>
</span>
{% if parentsChildrenOrdered is not empty %}
@ -48,5 +48,10 @@
Save
</span>
</header>
{% if page is not empty %}
<main>
<input type="text" class="uri-input" name="uri-input" placeholder="Uri(Optional)" value="{{ page.uri }}">
</main>
{% endif %}
</section>
{% endblock %}

View file

@ -7,7 +7,12 @@
Documentation
</a>
{% if page._parent %}
<a href="/page/{{ page._parent }}" class="page__header-nav">
<a class="page__header-nav"
{% if pageParent.uri %}
href="/{{ pageParent.uri }}"
{% else %}
href="/page/{{ pageParent._id }}"
{% endif %}>
Parent {{ pageParent.title }}
</a>
{% endif %}