From 3a3b2bcfc0cf3028d1b6fa48631da75928d20147 Mon Sep 17 00:00:00 2001 From: "E.Gavrilov" Date: Mon, 18 May 2026 02:48:47 +0300 Subject: [PATCH] add simple admin service --- .dockerignore | 1 + README.md | 9 + local-admin/posts-admin.mjs | 3 + local-admin/public/index.html | 126 ++++++ local-admin/public/src/app/bindEvents.js | 38 ++ local-admin/public/src/app/bootstrap.js | 15 + .../public/src/app/createAdminContext.js | 50 +++ local-admin/public/src/app/dom.js | 39 ++ local-admin/public/src/app/main.js | 11 + .../public/src/app/model/createAdminState.js | 9 + .../public/src/entities/article/api.js | 31 ++ local-admin/public/src/entities/asset/api.js | 16 + .../src/features/article-actions/buttons.js | 4 + .../article-actions/createNewArticle.js | 17 + .../features/article-actions/deleteArticle.js | 29 ++ .../article-actions/duplicateArticle.js | 20 + .../article-actions/reloadArticles.js | 13 + .../article-actions/renderArticles.js | 11 + .../features/article-actions/saveArticle.js | 21 + .../features/article-actions/selectArticle.js | 28 ++ .../article-editor/createArticleDraft.js | 15 + .../article-editor/fillArticleForm.js | 13 + .../src/features/article-editor/form.js | 2 + .../article-editor/readArticleForm.js | 22 + .../src/features/asset-picker/mergeAssets.js | 5 + .../asset-picker/renderAssetOptions.js | 9 + .../features/asset-upload/assetToMarkdown.js | 4 + .../features/asset-upload/upload-assets.js | 21 + .../src/features/asset-upload/uploadCover.js | 26 ++ .../asset-upload/uploadMarkdownAssets.js | 28 ++ .../markdown-editor/applyMarkdownFormat.js | 30 ++ .../markdown-editor/bindMarkdownToolbar.js | 7 + .../features/markdown-editor/formatting.js | 2 + .../markdown-editor/insertAtCursor.js | 14 + .../markdown-editor/prefixSelectionLines.js | 15 + .../features/markdown-editor/wrapSelection.js | 11 + .../features/preview-panel/renderPreview.js | 19 + .../src/features/session-state/dirtyState.js | 20 + .../src/features/session-state/status.js | 3 + local-admin/public/src/shared/api/http.js | 10 + local-admin/public/src/shared/lib/date.js | 13 + local-admin/public/src/shared/lib/file.js | 8 + local-admin/public/src/shared/lib/markdown.js | 4 + .../src/shared/lib/markdown/escapeHtml.js | 12 + .../lib/markdown/estimateReadingMinutes.js | 10 + .../src/shared/lib/markdown/htmlToMarkdown.js | 9 + .../src/shared/lib/markdown/inlineToHtml.js | 33 ++ .../src/shared/lib/markdown/markdownToHtml.js | 125 ++++++ .../src/shared/lib/markdown/nodeToMarkdown.js | 73 ++++ .../shared/lib/markdown/normalizeAssetUrl.js | 9 + .../shared/lib/markdown/normalizeMarkdown.js | 6 + .../src/shared/lib/markdown/previewStyles.js | 13 + local-admin/public/src/shared/lib/slug.js | 24 ++ .../public/src/widgets/article-list/ui.js | 39 ++ local-admin/public/styles.css | 386 ++++++++++++++++++ local-admin/src/app/requestHandler.mjs | 71 ++++ local-admin/src/app/startServer.mjs | 30 ++ local-admin/src/config/limits.mjs | 4 + local-admin/src/config/paths.mjs | 13 + local-admin/src/config/server.mjs | 4 + .../src/entities/article/articleHandlers.mjs | 4 + .../entities/article/articleRepository.mjs | 20 + .../src/entities/article/articleValidator.mjs | 78 ++++ .../entities/article/handleCreateArticle.mjs | 16 + .../entities/article/handleDeleteArticle.mjs | 17 + .../entities/article/handleListArticles.mjs | 6 + .../entities/article/handleUpdateArticle.mjs | 22 + .../src/entities/asset/assetHandlers.mjs | 2 + local-admin/src/entities/asset/assetTypes.mjs | 20 + .../src/entities/asset/getUniqueFileName.mjs | 24 ++ .../src/entities/asset/handleListAssets.mjs | 8 + .../src/entities/asset/handleUploadAsset.mjs | 62 +++ local-admin/src/entities/asset/listAssets.mjs | 24 ++ .../src/entities/asset/parseUploadPayload.mjs | 19 + .../src/entities/asset/sanitizeFileName.mjs | 17 + local-admin/src/shared/http/httpError.mjs | 5 + local-admin/src/shared/http/readJsonBody.mjs | 27 ++ local-admin/src/shared/http/sendError.mjs | 12 + local-admin/src/shared/http/sendJson.mjs | 10 + local-admin/src/shared/static/mimeTypes.mjs | 25 ++ .../src/shared/static/resolveInside.mjs | 13 + local-admin/src/shared/static/serveFile.mjs | 44 ++ web/data/articles.json | 10 +- web/package.json | 1 + 84 files changed, 2134 insertions(+), 5 deletions(-) create mode 100644 local-admin/posts-admin.mjs create mode 100644 local-admin/public/index.html create mode 100644 local-admin/public/src/app/bindEvents.js create mode 100644 local-admin/public/src/app/bootstrap.js create mode 100644 local-admin/public/src/app/createAdminContext.js create mode 100644 local-admin/public/src/app/dom.js create mode 100644 local-admin/public/src/app/main.js create mode 100644 local-admin/public/src/app/model/createAdminState.js create mode 100644 local-admin/public/src/entities/article/api.js create mode 100644 local-admin/public/src/entities/asset/api.js create mode 100644 local-admin/public/src/features/article-actions/buttons.js create mode 100644 local-admin/public/src/features/article-actions/createNewArticle.js create mode 100644 local-admin/public/src/features/article-actions/deleteArticle.js create mode 100644 local-admin/public/src/features/article-actions/duplicateArticle.js create mode 100644 local-admin/public/src/features/article-actions/reloadArticles.js create mode 100644 local-admin/public/src/features/article-actions/renderArticles.js create mode 100644 local-admin/public/src/features/article-actions/saveArticle.js create mode 100644 local-admin/public/src/features/article-actions/selectArticle.js create mode 100644 local-admin/public/src/features/article-editor/createArticleDraft.js create mode 100644 local-admin/public/src/features/article-editor/fillArticleForm.js create mode 100644 local-admin/public/src/features/article-editor/form.js create mode 100644 local-admin/public/src/features/article-editor/readArticleForm.js create mode 100644 local-admin/public/src/features/asset-picker/mergeAssets.js create mode 100644 local-admin/public/src/features/asset-picker/renderAssetOptions.js create mode 100644 local-admin/public/src/features/asset-upload/assetToMarkdown.js create mode 100644 local-admin/public/src/features/asset-upload/upload-assets.js create mode 100644 local-admin/public/src/features/asset-upload/uploadCover.js create mode 100644 local-admin/public/src/features/asset-upload/uploadMarkdownAssets.js create mode 100644 local-admin/public/src/features/markdown-editor/applyMarkdownFormat.js create mode 100644 local-admin/public/src/features/markdown-editor/bindMarkdownToolbar.js create mode 100644 local-admin/public/src/features/markdown-editor/formatting.js create mode 100644 local-admin/public/src/features/markdown-editor/insertAtCursor.js create mode 100644 local-admin/public/src/features/markdown-editor/prefixSelectionLines.js create mode 100644 local-admin/public/src/features/markdown-editor/wrapSelection.js create mode 100644 local-admin/public/src/features/preview-panel/renderPreview.js create mode 100644 local-admin/public/src/features/session-state/dirtyState.js create mode 100644 local-admin/public/src/features/session-state/status.js create mode 100644 local-admin/public/src/shared/api/http.js create mode 100644 local-admin/public/src/shared/lib/date.js create mode 100644 local-admin/public/src/shared/lib/file.js create mode 100644 local-admin/public/src/shared/lib/markdown.js create mode 100644 local-admin/public/src/shared/lib/markdown/escapeHtml.js create mode 100644 local-admin/public/src/shared/lib/markdown/estimateReadingMinutes.js create mode 100644 local-admin/public/src/shared/lib/markdown/htmlToMarkdown.js create mode 100644 local-admin/public/src/shared/lib/markdown/inlineToHtml.js create mode 100644 local-admin/public/src/shared/lib/markdown/markdownToHtml.js create mode 100644 local-admin/public/src/shared/lib/markdown/nodeToMarkdown.js create mode 100644 local-admin/public/src/shared/lib/markdown/normalizeAssetUrl.js create mode 100644 local-admin/public/src/shared/lib/markdown/normalizeMarkdown.js create mode 100644 local-admin/public/src/shared/lib/markdown/previewStyles.js create mode 100644 local-admin/public/src/shared/lib/slug.js create mode 100644 local-admin/public/src/widgets/article-list/ui.js create mode 100644 local-admin/public/styles.css create mode 100644 local-admin/src/app/requestHandler.mjs create mode 100644 local-admin/src/app/startServer.mjs create mode 100644 local-admin/src/config/limits.mjs create mode 100644 local-admin/src/config/paths.mjs create mode 100644 local-admin/src/config/server.mjs create mode 100644 local-admin/src/entities/article/articleHandlers.mjs create mode 100644 local-admin/src/entities/article/articleRepository.mjs create mode 100644 local-admin/src/entities/article/articleValidator.mjs create mode 100644 local-admin/src/entities/article/handleCreateArticle.mjs create mode 100644 local-admin/src/entities/article/handleDeleteArticle.mjs create mode 100644 local-admin/src/entities/article/handleListArticles.mjs create mode 100644 local-admin/src/entities/article/handleUpdateArticle.mjs create mode 100644 local-admin/src/entities/asset/assetHandlers.mjs create mode 100644 local-admin/src/entities/asset/assetTypes.mjs create mode 100644 local-admin/src/entities/asset/getUniqueFileName.mjs create mode 100644 local-admin/src/entities/asset/handleListAssets.mjs create mode 100644 local-admin/src/entities/asset/handleUploadAsset.mjs create mode 100644 local-admin/src/entities/asset/listAssets.mjs create mode 100644 local-admin/src/entities/asset/parseUploadPayload.mjs create mode 100644 local-admin/src/entities/asset/sanitizeFileName.mjs create mode 100644 local-admin/src/shared/http/httpError.mjs create mode 100644 local-admin/src/shared/http/readJsonBody.mjs create mode 100644 local-admin/src/shared/http/sendError.mjs create mode 100644 local-admin/src/shared/http/sendJson.mjs create mode 100644 local-admin/src/shared/static/mimeTypes.mjs create mode 100644 local-admin/src/shared/static/resolveInside.mjs create mode 100644 local-admin/src/shared/static/serveFile.mjs diff --git a/.dockerignore b/.dockerignore index eb2c3fd..9933c86 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,6 +1,7 @@ .git .gitea .webarchive +local-admin deploy web/.next web/node_modules diff --git a/README.md b/README.md index 08943fd..fc6a0f1 100644 --- a/README.md +++ b/README.md @@ -14,11 +14,20 @@ Useful commands: ```bash cd ~/tmp/progcode/web npm install +ADMIN_PORT=3333 npm run admin:posts node scripts/buildDatabase.mjs npm run dev -- --port 3000 npm run build ``` +Local posts admin: + +- run from `web/` with `npm run admin:posts`; +- open `http://127.0.0.1:3333`; +- it edits posts as Markdown and saves generated `contentHtml` to `web/data/articles.json`; +- uploaded files are stored in `web/public/assets/uploads/`; +- it lives in `local-admin/`, outside the Next.js app, and `.dockerignore` excludes it from Docker builds. + Docker static deploy: ```bash diff --git a/local-admin/posts-admin.mjs b/local-admin/posts-admin.mjs new file mode 100644 index 0000000..79b66f2 --- /dev/null +++ b/local-admin/posts-admin.mjs @@ -0,0 +1,3 @@ +import { startServer } from './src/app/startServer.mjs'; + +startServer(); diff --git a/local-admin/public/index.html b/local-admin/public/index.html new file mode 100644 index 0000000..b9d7f24 --- /dev/null +++ b/local-admin/public/index.html @@ -0,0 +1,126 @@ + + + + + + Локальная админка постов + + + +
+
+

Только localhost

+

Посты ProgCode.Ru

+
+
+ + + +
+
+ +
+ + +
+
+ Загрузка... + Открыть статью +
+ +
+
+ + + + + + + + + + + + + + + +
+ +
+ + + +
+ +
+
+
+ Markdown статьи +
+ + + + + + + + + +
+
+ + +
+ +
+
+ +
+ +
+
+
+
+
+ + + + diff --git a/local-admin/public/src/app/bindEvents.js b/local-admin/public/src/app/bindEvents.js new file mode 100644 index 0000000..bc193f1 --- /dev/null +++ b/local-admin/public/src/app/bindEvents.js @@ -0,0 +1,38 @@ +import { bindMarkdownToolbar } from '../features/markdown-editor/formatting.js'; +import { estimateReadingMinutes } from '../shared/lib/markdown.js'; +import { slugify } from '../shared/lib/slug.js'; + +export function bindEvents(context) { + context.form.addEventListener('input', () => { + context.setDirty(true); + context.renderPreview(); + }); + + context.searchInput.addEventListener('input', context.renderArticles); + context.newButton.addEventListener('click', context.createNewArticle); + context.duplicateButton.addEventListener('click', context.duplicateArticle); + context.saveButton.addEventListener('click', context.saveArticle); + context.deleteButton.addEventListener('click', context.deleteArticle); + context.coverUploadButton.addEventListener('click', () => context.coverUploadInput.click()); + context.coverUploadInput.addEventListener('change', context.uploadCover); + context.assetUploadButton.addEventListener('click', () => context.assetUploadInput.click()); + context.assetUploadInput.addEventListener('change', context.uploadMarkdownAssets); + + bindMarkdownToolbar({ + field: context.fields.contentMarkdown, + onChange() { + context.setDirty(true); + context.renderPreview(); + }, + }); + + context.slugButton.addEventListener('click', () => { + context.fields.slug.value = slugify(context.fields.title.value); + context.setDirty(true); + }); + + context.minutesButton.addEventListener('click', () => { + context.fields.readingMinutes.value = estimateReadingMinutes(context.fields.contentMarkdown.value); + context.setDirty(true); + }); +} diff --git a/local-admin/public/src/app/bootstrap.js b/local-admin/public/src/app/bootstrap.js new file mode 100644 index 0000000..8835bc5 --- /dev/null +++ b/local-admin/public/src/app/bootstrap.js @@ -0,0 +1,15 @@ +import { getArticles } from '../entities/article/api.js'; + +export async function bootstrap(context) { + context.setStatus('Загрузка данных...'); + + try { + const [articles] = await Promise.all([getArticles(), context.loadAssets()]); + context.state.articles = articles; + context.renderArticles(); + context.selectArticle(context.state.articles[0]?.slug || null); + context.setStatus(`Загружено ${context.state.articles.length} статей`); + } catch (error) { + context.setStatus(error.message); + } +} diff --git a/local-admin/public/src/app/createAdminContext.js b/local-admin/public/src/app/createAdminContext.js new file mode 100644 index 0000000..f045c1a --- /dev/null +++ b/local-admin/public/src/app/createAdminContext.js @@ -0,0 +1,50 @@ +import { getAssets } from '../entities/asset/api.js'; +import { deleteArticle } from '../features/article-actions/deleteArticle.js'; +import { duplicateArticle } from '../features/article-actions/duplicateArticle.js'; +import { createNewArticle } from '../features/article-actions/createNewArticle.js'; +import { reloadArticles } from '../features/article-actions/reloadArticles.js'; +import { renderArticles } from '../features/article-actions/renderArticles.js'; +import { saveArticle } from '../features/article-actions/saveArticle.js'; +import { selectArticle } from '../features/article-actions/selectArticle.js'; +import { updateArticleButtons } from '../features/article-actions/buttons.js'; +import { mergeAssetPaths } from '../features/asset-picker/mergeAssets.js'; +import { renderAssetOptions } from '../features/asset-picker/renderAssetOptions.js'; +import { uploadCover } from '../features/asset-upload/uploadCover.js'; +import { uploadMarkdownAssets } from '../features/asset-upload/uploadMarkdownAssets.js'; +import { renderPreview } from '../features/preview-panel/renderPreview.js'; +import { confirmDiscard, setDirty } from '../features/session-state/dirtyState.js'; +import { setStatus } from '../features/session-state/status.js'; +import { createAdminState } from './model/createAdminState.js'; + +export function createAdminContext(dom) { + const context = { + ...dom, + state: createAdminState(), + }; + + context.confirmDiscard = () => confirmDiscard(context.state); + context.deleteArticle = () => deleteArticle(context); + context.duplicateArticle = () => duplicateArticle(context); + context.createNewArticle = () => createNewArticle(context); + context.loadAssets = async () => { + context.state.assets = await getAssets(); + context.renderAssetOptions(); + }; + context.mergeUploadedAssets = (uploaded) => { + context.state.assets = mergeAssetPaths(context.state.assets, uploaded); + context.renderAssetOptions(); + }; + context.reloadArticles = (selectedSlug) => reloadArticles(context, selectedSlug); + context.renderArticles = () => renderArticles(context, context.selectArticle); + context.renderAssetOptions = () => renderAssetOptions(context.assetOptions, context.state.assets); + context.renderPreview = () => renderPreview(context); + context.saveArticle = () => saveArticle(context); + context.selectArticle = (slug, options) => selectArticle(slug, context, options); + context.setDirty = (dirty) => setDirty({ dirty, saveButton: context.saveButton, state: context.state }); + context.setStatus = (message) => setStatus(context.statusText, message); + context.updateButtons = () => updateArticleButtons(context); + context.uploadCover = () => uploadCover(context); + context.uploadMarkdownAssets = () => uploadMarkdownAssets(context); + + return context; +} diff --git a/local-admin/public/src/app/dom.js b/local-admin/public/src/app/dom.js new file mode 100644 index 0000000..7a75134 --- /dev/null +++ b/local-admin/public/src/app/dom.js @@ -0,0 +1,39 @@ +export function getAdminDom() { + const form = document.querySelector('#article-form'); + + return { + articleList: document.querySelector('#article-list'), + assetOptions: document.querySelector('#asset-options'), + assetUploadButton: document.querySelector('#asset-upload-button'), + assetUploadInput: document.querySelector('#asset-upload-input'), + contentPreview: document.querySelector('#content-preview'), + coverPreview: document.querySelector('#cover-preview'), + coverUploadButton: document.querySelector('#cover-upload-button'), + coverUploadInput: document.querySelector('#cover-upload-input'), + deleteButton: document.querySelector('#delete-button'), + duplicateButton: document.querySelector('#duplicate-button'), + form, + fields: getArticleFields(form), + minutesButton: document.querySelector('#minutes-button'), + newButton: document.querySelector('#new-button'), + openArticleLink: document.querySelector('#open-article-link'), + saveButton: document.querySelector('#save-button'), + searchInput: document.querySelector('#search-input'), + slugButton: document.querySelector('#slug-button'), + statusText: document.querySelector('#status-text'), + }; +} + +function getArticleFields(form) { + return { + author: form.elements.author, + categories: form.elements.categories, + contentMarkdown: form.elements.contentMarkdown, + cover: form.elements.cover, + date: form.elements.date, + excerpt: form.elements.excerpt, + readingMinutes: form.elements.readingMinutes, + slug: form.elements.slug, + title: form.elements.title, + }; +} diff --git a/local-admin/public/src/app/main.js b/local-admin/public/src/app/main.js new file mode 100644 index 0000000..3ef393d --- /dev/null +++ b/local-admin/public/src/app/main.js @@ -0,0 +1,11 @@ +import { protectUnsavedChanges } from '../features/session-state/dirtyState.js'; +import { bootstrap } from './bootstrap.js'; +import { bindEvents } from './bindEvents.js'; +import { createAdminContext } from './createAdminContext.js'; +import { getAdminDom } from './dom.js'; + +const context = createAdminContext(getAdminDom()); + +bindEvents(context); +protectUnsavedChanges(context.state); +await bootstrap(context); diff --git a/local-admin/public/src/app/model/createAdminState.js b/local-admin/public/src/app/model/createAdminState.js new file mode 100644 index 0000000..e5745f0 --- /dev/null +++ b/local-admin/public/src/app/model/createAdminState.js @@ -0,0 +1,9 @@ +export function createAdminState() { + return { + articles: [], + assets: [], + current: null, + dirty: false, + selectedSlug: null, + }; +} diff --git a/local-admin/public/src/entities/article/api.js b/local-admin/public/src/entities/article/api.js new file mode 100644 index 0000000..df489f4 --- /dev/null +++ b/local-admin/public/src/entities/article/api.js @@ -0,0 +1,31 @@ +import { fetchJson } from '../../shared/api/http.js'; + +export async function getArticles() { + const response = await fetchJson('/api/articles'); + return response.articles; +} + +export async function createArticle(article) { + const response = await fetchJson('/api/articles', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ article }), + }); + + return response.article; +} + +export async function updateArticle(slug, article) { + const response = await fetchJson(`/api/articles/${encodeURIComponent(slug)}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ article }), + }); + + return response.article; +} + +export async function deleteArticle(slug) { + const response = await fetchJson(`/api/articles/${encodeURIComponent(slug)}`, { method: 'DELETE' }); + return response.article; +} diff --git a/local-admin/public/src/entities/asset/api.js b/local-admin/public/src/entities/asset/api.js new file mode 100644 index 0000000..f1477ba --- /dev/null +++ b/local-admin/public/src/entities/asset/api.js @@ -0,0 +1,16 @@ +import { fetchJson } from '../../shared/api/http.js'; + +export async function getAssets() { + const response = await fetchJson('/api/assets'); + return response.assets; +} + +export async function uploadAsset({ dataUrl, fileName }) { + const response = await fetchJson('/api/assets', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ dataUrl, fileName }), + }); + + return response.asset; +} diff --git a/local-admin/public/src/features/article-actions/buttons.js b/local-admin/public/src/features/article-actions/buttons.js new file mode 100644 index 0000000..e260345 --- /dev/null +++ b/local-admin/public/src/features/article-actions/buttons.js @@ -0,0 +1,4 @@ +export function updateArticleButtons({ deleteButton, duplicateButton, state }) { + duplicateButton.disabled = !state.current; + deleteButton.disabled = !state.selectedSlug; +} diff --git a/local-admin/public/src/features/article-actions/createNewArticle.js b/local-admin/public/src/features/article-actions/createNewArticle.js new file mode 100644 index 0000000..c40aeb7 --- /dev/null +++ b/local-admin/public/src/features/article-actions/createNewArticle.js @@ -0,0 +1,17 @@ +import { createArticleDraft } from '../article-editor/createArticleDraft.js'; +import { fillArticleForm } from '../article-editor/form.js'; + +export function createNewArticle(context) { + if (!context.confirmDiscard()) { + return; + } + + context.state.current = createArticleDraft({ articles: context.state.articles, assets: context.state.assets }); + context.state.selectedSlug = null; + fillArticleForm(context.fields, context.state.current); + context.setStatus('Новая статья еще не сохранена'); + context.setDirty(true); + context.updateButtons(); + context.renderPreview(); + context.renderArticles(); +} diff --git a/local-admin/public/src/features/article-actions/deleteArticle.js b/local-admin/public/src/features/article-actions/deleteArticle.js new file mode 100644 index 0000000..ab21aa6 --- /dev/null +++ b/local-admin/public/src/features/article-actions/deleteArticle.js @@ -0,0 +1,29 @@ +import { deleteArticle as deleteArticleRequest } from '../../entities/article/api.js'; + +export async function deleteArticle(context) { + if (!context.state.selectedSlug || !context.state.current) { + return; + } + + const confirmed = window.confirm(`Удалить статью "${context.state.current.title}"? Это изменит articles.json.`); + if (!confirmed) { + return; + } + + context.setStatus('Удаление...'); + context.deleteButton.disabled = true; + + try { + await deleteArticleRequest(context.state.selectedSlug); + context.state.current = null; + context.state.selectedSlug = null; + context.setDirty(false); + await context.reloadArticles(null); + context.setStatus('Статья удалена'); + } catch (error) { + context.setStatus(error.message); + } finally { + context.deleteButton.disabled = false; + context.updateButtons(); + } +} diff --git a/local-admin/public/src/features/article-actions/duplicateArticle.js b/local-admin/public/src/features/article-actions/duplicateArticle.js new file mode 100644 index 0000000..42d9943 --- /dev/null +++ b/local-admin/public/src/features/article-actions/duplicateArticle.js @@ -0,0 +1,20 @@ +import { fillArticleForm } from '../article-editor/form.js'; +import { uniqueDraftSlug } from '../../shared/lib/slug.js'; + +export function duplicateArticle(context) { + if (!context.state.current || !context.confirmDiscard()) { + return; + } + + const copy = structuredClone(context.state.current); + copy.slug = uniqueDraftSlug(`${copy.slug}-copy`, context.state.articles); + copy.title = `${copy.title} - копия`; + context.state.current = copy; + context.state.selectedSlug = null; + fillArticleForm(context.fields, copy); + context.setStatus('Дубликат еще не сохранен'); + context.setDirty(true); + context.updateButtons(); + context.renderPreview(); + context.renderArticles(); +} diff --git a/local-admin/public/src/features/article-actions/reloadArticles.js b/local-admin/public/src/features/article-actions/reloadArticles.js new file mode 100644 index 0000000..2e8ceab --- /dev/null +++ b/local-admin/public/src/features/article-actions/reloadArticles.js @@ -0,0 +1,13 @@ +import { getArticles } from '../../entities/article/api.js'; + +export async function reloadArticles(context, selectedSlug = context.state.selectedSlug) { + context.state.articles = await getArticles(); + context.renderArticles(); + + if (selectedSlug && context.state.articles.some((article) => article.slug === selectedSlug)) { + context.selectArticle(selectedSlug, { skipConfirm: true }); + return; + } + + context.selectArticle(context.state.articles[0]?.slug || null, { skipConfirm: true }); +} diff --git a/local-admin/public/src/features/article-actions/renderArticles.js b/local-admin/public/src/features/article-actions/renderArticles.js new file mode 100644 index 0000000..0bd519e --- /dev/null +++ b/local-admin/public/src/features/article-actions/renderArticles.js @@ -0,0 +1,11 @@ +import { renderArticleList } from '../../widgets/article-list/ui.js'; + +export function renderArticles({ articleList, searchInput, state }, onSelect) { + renderArticleList({ + articles: state.articles, + container: articleList, + onSelect, + query: searchInput.value, + selectedSlug: state.selectedSlug, + }); +} diff --git a/local-admin/public/src/features/article-actions/saveArticle.js b/local-admin/public/src/features/article-actions/saveArticle.js new file mode 100644 index 0000000..251db5c --- /dev/null +++ b/local-admin/public/src/features/article-actions/saveArticle.js @@ -0,0 +1,21 @@ +import { createArticle, updateArticle } from '../../entities/article/api.js'; +import { readArticleForm } from '../article-editor/form.js'; + +export async function saveArticle(context) { + const article = readArticleForm(context.fields, context.state.current); + const isNew = !context.state.selectedSlug; + + context.setStatus('Сохранение...'); + context.saveButton.disabled = true; + + try { + const savedArticle = isNew ? await createArticle(article) : await updateArticle(context.state.selectedSlug, article); + await context.reloadArticles(savedArticle.slug); + context.setStatus(`Сохранено: ${savedArticle.title}`); + } catch (error) { + context.setStatus(error.message); + } finally { + context.saveButton.disabled = false; + context.updateButtons(); + } +} diff --git a/local-admin/public/src/features/article-actions/selectArticle.js b/local-admin/public/src/features/article-actions/selectArticle.js new file mode 100644 index 0000000..c271baf --- /dev/null +++ b/local-admin/public/src/features/article-actions/selectArticle.js @@ -0,0 +1,28 @@ +import { fillArticleForm } from '../article-editor/form.js'; + +export function selectArticle(slug, context, options = {}) { + if (!options.skipConfirm && !context.confirmDiscard()) { + return; + } + + const article = context.state.articles.find((item) => item.slug === slug) || null; + context.state.current = article ? structuredClone(article) : null; + context.state.selectedSlug = article?.slug || null; + + if (!context.state.current) { + context.form.reset(); + context.setStatus('Нет статей'); + context.setDirty(false); + context.updateButtons(); + context.renderPreview(); + context.renderArticles(); + return; + } + + fillArticleForm(context.fields, context.state.current); + context.setStatus(`Редактируется: ${context.state.current.title}`); + context.setDirty(false); + context.updateButtons(); + context.renderPreview(); + context.renderArticles(); +} diff --git a/local-admin/public/src/features/article-editor/createArticleDraft.js b/local-admin/public/src/features/article-editor/createArticleDraft.js new file mode 100644 index 0000000..f0cc7dc --- /dev/null +++ b/local-admin/public/src/features/article-editor/createArticleDraft.js @@ -0,0 +1,15 @@ +import { markdownToHtml } from '../../shared/lib/markdown.js'; + +export function createArticleDraft({ articles, assets }) { + return { + slug: `new-post-${Date.now()}`, + title: 'Новая статья', + date: new Date().toISOString(), + author: articles[0]?.author || 'DarkRiDDeR', + categories: articles[0]?.categories || ['Разное'], + cover: assets[0] || '/assets/illustrations/vibe-cover.svg', + excerpt: 'Короткий анонс статьи.', + contentHtml: markdownToHtml('Текст статьи.'), + readingMinutes: 1, + }; +} diff --git a/local-admin/public/src/features/article-editor/fillArticleForm.js b/local-admin/public/src/features/article-editor/fillArticleForm.js new file mode 100644 index 0000000..5413dbe --- /dev/null +++ b/local-admin/public/src/features/article-editor/fillArticleForm.js @@ -0,0 +1,13 @@ +import { htmlToMarkdown } from '../../shared/lib/markdown.js'; + +export function fillArticleForm(fields, article) { + fields.title.value = article.title || ''; + fields.slug.value = article.slug || ''; + fields.date.value = article.date || ''; + fields.author.value = article.author || ''; + fields.categories.value = (article.categories || []).join(', '); + fields.cover.value = article.cover || ''; + fields.excerpt.value = article.excerpt || ''; + fields.contentMarkdown.value = article.contentMarkdown || htmlToMarkdown(article.contentHtml || ''); + fields.readingMinutes.value = article.readingMinutes || 1; +} diff --git a/local-admin/public/src/features/article-editor/form.js b/local-admin/public/src/features/article-editor/form.js new file mode 100644 index 0000000..8454219 --- /dev/null +++ b/local-admin/public/src/features/article-editor/form.js @@ -0,0 +1,2 @@ +export { fillArticleForm } from './fillArticleForm.js'; +export { readArticleForm } from './readArticleForm.js'; diff --git a/local-admin/public/src/features/article-editor/readArticleForm.js b/local-admin/public/src/features/article-editor/readArticleForm.js new file mode 100644 index 0000000..d0caf8c --- /dev/null +++ b/local-admin/public/src/features/article-editor/readArticleForm.js @@ -0,0 +1,22 @@ +import { markdownToHtml } from '../../shared/lib/markdown.js'; + +export function readArticleForm(fields, currentArticle) { + const article = { + ...(currentArticle || {}), + slug: fields.slug.value.trim(), + title: fields.title.value.trim(), + date: fields.date.value.trim(), + author: fields.author.value.trim(), + categories: fields.categories.value + .split(',') + .map((category) => category.trim()) + .filter(Boolean), + cover: fields.cover.value.trim(), + excerpt: fields.excerpt.value.trim(), + contentHtml: markdownToHtml(fields.contentMarkdown.value), + readingMinutes: Number(fields.readingMinutes.value), + }; + + delete article.contentMarkdown; + return article; +} diff --git a/local-admin/public/src/features/asset-picker/mergeAssets.js b/local-admin/public/src/features/asset-picker/mergeAssets.js new file mode 100644 index 0000000..f8bc33c --- /dev/null +++ b/local-admin/public/src/features/asset-picker/mergeAssets.js @@ -0,0 +1,5 @@ +export function mergeAssetPaths(currentAssets, uploadedAssets) { + return [...new Set([...uploadedAssets.map((asset) => asset.path), ...currentAssets])].sort((left, right) => + left.localeCompare(right, 'ru'), + ); +} diff --git a/local-admin/public/src/features/asset-picker/renderAssetOptions.js b/local-admin/public/src/features/asset-picker/renderAssetOptions.js new file mode 100644 index 0000000..bbf707a --- /dev/null +++ b/local-admin/public/src/features/asset-picker/renderAssetOptions.js @@ -0,0 +1,9 @@ +export function renderAssetOptions(assetOptions, assets) { + assetOptions.replaceChildren( + ...assets.map((asset) => { + const option = document.createElement('option'); + option.value = asset; + return option; + }), + ); +} diff --git a/local-admin/public/src/features/asset-upload/assetToMarkdown.js b/local-admin/public/src/features/asset-upload/assetToMarkdown.js new file mode 100644 index 0000000..a6920c3 --- /dev/null +++ b/local-admin/public/src/features/asset-upload/assetToMarkdown.js @@ -0,0 +1,4 @@ +export function assetToMarkdown(asset, file) { + const label = file.name.replace(/\.[^.]+$/, '') || asset.fileName; + return asset.isImage ? `![${label}](${asset.path})` : `[${label}](${asset.path})`; +} diff --git a/local-admin/public/src/features/asset-upload/upload-assets.js b/local-admin/public/src/features/asset-upload/upload-assets.js new file mode 100644 index 0000000..f65891e --- /dev/null +++ b/local-admin/public/src/features/asset-upload/upload-assets.js @@ -0,0 +1,21 @@ +import { uploadAsset } from '../../entities/asset/api.js'; +import { readFileAsDataUrl } from '../../shared/lib/file.js'; + +export async function uploadFiles(files, options) { + const uploaded = []; + options.setStatus(`Загрузка файлов: ${files.length}`); + + try { + for (const file of files) { + const dataUrl = await readFileAsDataUrl(file); + const asset = await uploadAsset({ dataUrl, fileName: file.name }); + uploaded.push(asset); + options.onUploaded(asset, file); + } + + options.onComplete(uploaded); + options.setStatus(`${options.statusPrefix}: ${uploaded.map((asset) => asset.path).join(', ')}`); + } catch (error) { + options.setStatus(error.message); + } +} diff --git a/local-admin/public/src/features/asset-upload/uploadCover.js b/local-admin/public/src/features/asset-upload/uploadCover.js new file mode 100644 index 0000000..af98a94 --- /dev/null +++ b/local-admin/public/src/features/asset-upload/uploadCover.js @@ -0,0 +1,26 @@ +import { uploadFiles } from './upload-assets.js'; + +export async function uploadCover(context) { + const [file] = context.coverUploadInput.files || []; + context.coverUploadInput.value = ''; + + if (!file) { + return; + } + + if (!file.type.startsWith('image/')) { + context.setStatus('Для обложки можно загрузить только изображение'); + return; + } + + await uploadFiles([file], { + onComplete: context.mergeUploadedAssets, + onUploaded(asset) { + context.fields.cover.value = asset.path; + context.setDirty(true); + context.renderPreview(); + }, + setStatus: context.setStatus, + statusPrefix: 'Обложка загружена', + }); +} diff --git a/local-admin/public/src/features/asset-upload/uploadMarkdownAssets.js b/local-admin/public/src/features/asset-upload/uploadMarkdownAssets.js new file mode 100644 index 0000000..17e6cc4 --- /dev/null +++ b/local-admin/public/src/features/asset-upload/uploadMarkdownAssets.js @@ -0,0 +1,28 @@ +import { insertAtCursor } from '../markdown-editor/formatting.js'; +import { assetToMarkdown } from './assetToMarkdown.js'; +import { uploadFiles } from './upload-assets.js'; + +export async function uploadMarkdownAssets(context) { + const files = Array.from(context.assetUploadInput.files || []); + context.assetUploadInput.value = ''; + + if (files.length === 0) { + return; + } + + await uploadFiles(files, { + onComplete: context.mergeUploadedAssets, + onUploaded(asset, file) { + insertAtCursor({ + field: context.fields.contentMarkdown, + onChange() { + context.setDirty(true); + context.renderPreview(); + }, + text: `${assetToMarkdown(asset, file)}\n`, + }); + }, + setStatus: context.setStatus, + statusPrefix: 'Ассет загружен', + }); +} diff --git a/local-admin/public/src/features/markdown-editor/applyMarkdownFormat.js b/local-admin/public/src/features/markdown-editor/applyMarkdownFormat.js new file mode 100644 index 0000000..f5c8103 --- /dev/null +++ b/local-admin/public/src/features/markdown-editor/applyMarkdownFormat.js @@ -0,0 +1,30 @@ +import { prefixSelectionLines } from './prefixSelectionLines.js'; +import { wrapSelection } from './wrapSelection.js'; + +export function applyMarkdownFormat({ field, format, onChange }) { + if (format === 'link') { + applyLinkFormat({ field, onChange }); + return; + } + + const actions = { + bold: () => wrapSelection({ fallback: 'жирный текст', field, onChange, prefix: '**', suffix: '**' }), + code: () => wrapSelection({ fallback: 'code', field, onChange, prefix: '`', suffix: '`' }), + h2: () => prefixSelectionLines({ field, onChange, prefix: '## ' }), + h3: () => prefixSelectionLines({ field, onChange, prefix: '### ' }), + italic: () => wrapSelection({ fallback: 'курсив', field, onChange, prefix: '*', suffix: '*' }), + list: () => prefixSelectionLines({ field, onChange, prefix: '- ' }), + quote: () => prefixSelectionLines({ field, onChange, prefix: '> ' }), + }; + + actions[format]?.(); +} + +function applyLinkFormat({ field, onChange }) { + const url = window.prompt('URL ссылки'); + if (!url) { + return; + } + + wrapSelection({ fallback: 'текст ссылки', field, onChange, prefix: '[', suffix: `](${url})` }); +} diff --git a/local-admin/public/src/features/markdown-editor/bindMarkdownToolbar.js b/local-admin/public/src/features/markdown-editor/bindMarkdownToolbar.js new file mode 100644 index 0000000..8f12cce --- /dev/null +++ b/local-admin/public/src/features/markdown-editor/bindMarkdownToolbar.js @@ -0,0 +1,7 @@ +import { applyMarkdownFormat } from './applyMarkdownFormat.js'; + +export function bindMarkdownToolbar({ field, onChange, root = document }) { + root.querySelectorAll('[data-format]').forEach((button) => { + button.addEventListener('click', () => applyMarkdownFormat({ field, format: button.dataset.format, onChange })); + }); +} diff --git a/local-admin/public/src/features/markdown-editor/formatting.js b/local-admin/public/src/features/markdown-editor/formatting.js new file mode 100644 index 0000000..a362dc3 --- /dev/null +++ b/local-admin/public/src/features/markdown-editor/formatting.js @@ -0,0 +1,2 @@ +export { bindMarkdownToolbar } from './bindMarkdownToolbar.js'; +export { insertAtCursor } from './insertAtCursor.js'; diff --git a/local-admin/public/src/features/markdown-editor/insertAtCursor.js b/local-admin/public/src/features/markdown-editor/insertAtCursor.js new file mode 100644 index 0000000..f216406 --- /dev/null +++ b/local-admin/public/src/features/markdown-editor/insertAtCursor.js @@ -0,0 +1,14 @@ +export function insertAtCursor({ field, onChange, text }) { + const start = field.selectionStart; + const end = field.selectionEnd; + const before = field.value.slice(0, start); + const after = field.value.slice(end); + const separatorBefore = before && !before.endsWith('\n') ? '\n' : ''; + const separatorAfter = after && !after.startsWith('\n') ? '\n' : ''; + + field.value = `${before}${separatorBefore}${text}${separatorAfter}${after}`; + const cursor = before.length + separatorBefore.length + text.length; + field.focus(); + field.setSelectionRange(cursor, cursor); + onChange(); +} diff --git a/local-admin/public/src/features/markdown-editor/prefixSelectionLines.js b/local-admin/public/src/features/markdown-editor/prefixSelectionLines.js new file mode 100644 index 0000000..dc0b13a --- /dev/null +++ b/local-admin/public/src/features/markdown-editor/prefixSelectionLines.js @@ -0,0 +1,15 @@ +export function prefixSelectionLines({ field, onChange, prefix }) { + const start = field.selectionStart; + const end = field.selectionEnd; + const lineStart = field.value.lastIndexOf('\n', Math.max(0, start - 1)) + 1; + const selected = field.value.slice(lineStart, end) || 'текст'; + const replacement = selected + .split('\n') + .map((line) => (line.startsWith(prefix) ? line : `${prefix}${line}`)) + .join('\n'); + + field.value = `${field.value.slice(0, lineStart)}${replacement}${field.value.slice(end)}`; + field.focus(); + field.setSelectionRange(lineStart, lineStart + replacement.length); + onChange(); +} diff --git a/local-admin/public/src/features/markdown-editor/wrapSelection.js b/local-admin/public/src/features/markdown-editor/wrapSelection.js new file mode 100644 index 0000000..4f2ff89 --- /dev/null +++ b/local-admin/public/src/features/markdown-editor/wrapSelection.js @@ -0,0 +1,11 @@ +export function wrapSelection({ fallback, field, onChange, prefix, suffix }) { + const start = field.selectionStart; + const end = field.selectionEnd; + const selected = field.value.slice(start, end) || fallback; + const nextValue = `${field.value.slice(0, start)}${prefix}${selected}${suffix}${field.value.slice(end)}`; + + field.value = nextValue; + field.focus(); + field.setSelectionRange(start + prefix.length, start + prefix.length + selected.length); + onChange(); +} diff --git a/local-admin/public/src/features/preview-panel/renderPreview.js b/local-admin/public/src/features/preview-panel/renderPreview.js new file mode 100644 index 0000000..eccf3d8 --- /dev/null +++ b/local-admin/public/src/features/preview-panel/renderPreview.js @@ -0,0 +1,19 @@ +import { markdownToHtml, previewStyles } from '../../shared/lib/markdown.js'; + +export function renderPreview({ contentPreview, coverPreview, fields, openArticleLink }) { + const cover = fields.cover.value.trim(); + coverPreview.src = cover || ''; + coverPreview.hidden = !cover; + + contentPreview.srcdoc = createPreviewDocument(fields.contentMarkdown.value); + updateArticleLink(openArticleLink, fields.slug.value.trim()); +} + +function createPreviewDocument(markdown) { + return `${markdownToHtml(markdown)}`; +} + +function updateArticleLink(openArticleLink, slug) { + openArticleLink.href = slug ? `http://localhost:3000/articles/${encodeURIComponent(slug)}/` : '#'; + openArticleLink.hidden = !slug; +} diff --git a/local-admin/public/src/features/session-state/dirtyState.js b/local-admin/public/src/features/session-state/dirtyState.js new file mode 100644 index 0000000..2e63a84 --- /dev/null +++ b/local-admin/public/src/features/session-state/dirtyState.js @@ -0,0 +1,20 @@ +export function setDirty({ dirty, saveButton, state }) { + state.dirty = dirty; + document.title = `${dirty ? '* ' : ''}Локальная админка постов`; + saveButton.textContent = dirty ? 'Сохранить *' : 'Сохранить'; +} + +export function confirmDiscard(state) { + return !state.dirty || window.confirm('Есть несохраненные изменения. Сбросить их?'); +} + +export function protectUnsavedChanges(state) { + window.addEventListener('beforeunload', (event) => { + if (!state.dirty) { + return; + } + + event.preventDefault(); + event.returnValue = ''; + }); +} diff --git a/local-admin/public/src/features/session-state/status.js b/local-admin/public/src/features/session-state/status.js new file mode 100644 index 0000000..88dc78e --- /dev/null +++ b/local-admin/public/src/features/session-state/status.js @@ -0,0 +1,3 @@ +export function setStatus(statusText, message) { + statusText.textContent = message; +} diff --git a/local-admin/public/src/shared/api/http.js b/local-admin/public/src/shared/api/http.js new file mode 100644 index 0000000..7307053 --- /dev/null +++ b/local-admin/public/src/shared/api/http.js @@ -0,0 +1,10 @@ +export async function fetchJson(url, options) { + const response = await fetch(url, options); + const payload = await response.json().catch(() => ({})); + + if (!response.ok) { + throw new Error(payload.error || `HTTP ${response.status}`); + } + + return payload; +} diff --git a/local-admin/public/src/shared/lib/date.js b/local-admin/public/src/shared/lib/date.js new file mode 100644 index 0000000..1c6389d --- /dev/null +++ b/local-admin/public/src/shared/lib/date.js @@ -0,0 +1,13 @@ +export function formatDate(value) { + const date = new Date(value); + + if (Number.isNaN(date.getTime())) { + return value; + } + + return new Intl.DateTimeFormat('ru-RU', { + day: '2-digit', + month: 'short', + year: 'numeric', + }).format(date); +} diff --git a/local-admin/public/src/shared/lib/file.js b/local-admin/public/src/shared/lib/file.js new file mode 100644 index 0000000..1cde6fe --- /dev/null +++ b/local-admin/public/src/shared/lib/file.js @@ -0,0 +1,8 @@ +export function readFileAsDataUrl(file) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.addEventListener('load', () => resolve(reader.result)); + reader.addEventListener('error', () => reject(reader.error)); + reader.readAsDataURL(file); + }); +} diff --git a/local-admin/public/src/shared/lib/markdown.js b/local-admin/public/src/shared/lib/markdown.js new file mode 100644 index 0000000..ec565fb --- /dev/null +++ b/local-admin/public/src/shared/lib/markdown.js @@ -0,0 +1,4 @@ +export { estimateReadingMinutes } from './markdown/estimateReadingMinutes.js'; +export { htmlToMarkdown } from './markdown/htmlToMarkdown.js'; +export { markdownToHtml } from './markdown/markdownToHtml.js'; +export { previewStyles } from './markdown/previewStyles.js'; diff --git a/local-admin/public/src/shared/lib/markdown/escapeHtml.js b/local-admin/public/src/shared/lib/markdown/escapeHtml.js new file mode 100644 index 0000000..e77ac47 --- /dev/null +++ b/local-admin/public/src/shared/lib/markdown/escapeHtml.js @@ -0,0 +1,12 @@ +export function escapeHtml(value) { + return String(value) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +export function escapeAttribute(value) { + return escapeHtml(value).replace(/`/g, '`'); +} diff --git a/local-admin/public/src/shared/lib/markdown/estimateReadingMinutes.js b/local-admin/public/src/shared/lib/markdown/estimateReadingMinutes.js new file mode 100644 index 0000000..69acc7a --- /dev/null +++ b/local-admin/public/src/shared/lib/markdown/estimateReadingMinutes.js @@ -0,0 +1,10 @@ +export function estimateReadingMinutes(markdown) { + const text = markdown + .replace(/```[\s\S]*?```/g, ' ') + .replace(/!\[[^\]]*\]\([^)]+\)/g, ' ') + .replace(/\[[^\]]+\]\([^)]+\)/g, ' ') + .replace(/[#>*_`-]/g, ' '); + const words = text.match(/[a-zа-я0-9]+/giu) || []; + + return Math.max(1, Math.ceil(words.length / 180)); +} diff --git a/local-admin/public/src/shared/lib/markdown/htmlToMarkdown.js b/local-admin/public/src/shared/lib/markdown/htmlToMarkdown.js new file mode 100644 index 0000000..f1fad07 --- /dev/null +++ b/local-admin/public/src/shared/lib/markdown/htmlToMarkdown.js @@ -0,0 +1,9 @@ +import { nodeToMarkdown } from './nodeToMarkdown.js'; +import { normalizeMarkdown } from './normalizeMarkdown.js'; + +export function htmlToMarkdown(html) { + const template = document.createElement('template'); + template.innerHTML = html; + + return normalizeMarkdown(Array.from(template.content.childNodes).map(nodeToMarkdown).join('')); +} diff --git a/local-admin/public/src/shared/lib/markdown/inlineToHtml.js b/local-admin/public/src/shared/lib/markdown/inlineToHtml.js new file mode 100644 index 0000000..af86744 --- /dev/null +++ b/local-admin/public/src/shared/lib/markdown/inlineToHtml.js @@ -0,0 +1,33 @@ +import { escapeAttribute, escapeHtml } from './escapeHtml.js'; +import { normalizeAssetUrl } from './normalizeAssetUrl.js'; + +export function inlineToHtml(value) { + const codeTokens = []; + let html = escapeHtml(value).replace(/`([^`]+)`/g, (_match, code) => { + const token = `@@CODE${codeTokens.length}@@`; + codeTokens.push(`${code}`); + return token; + }); + + html = html + .replace(/!\[([^\]]*)\]\(([^)\s]+)\)/g, (match, alt, url) => renderImage(match, alt, url)) + .replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (match, text, url) => renderLink(match, text, url)) + .replace(/\*\*([^*]+)\*\*/g, '$1') + .replace(/\*([^*]+)\*/g, '$1'); + + codeTokens.forEach((code, index) => { + html = html.replace(`@@CODE${index}@@`, code); + }); + + return html; +} + +function renderImage(match, alt, url) { + const safeUrl = normalizeAssetUrl(url); + return safeUrl ? `${escapeAttribute(alt)}` : escapeHtml(match); +} + +function renderLink(match, text, url) { + const safeUrl = normalizeAssetUrl(url); + return safeUrl ? `${text}` : escapeHtml(match); +} diff --git a/local-admin/public/src/shared/lib/markdown/markdownToHtml.js b/local-admin/public/src/shared/lib/markdown/markdownToHtml.js new file mode 100644 index 0000000..3fbc654 --- /dev/null +++ b/local-admin/public/src/shared/lib/markdown/markdownToHtml.js @@ -0,0 +1,125 @@ +import { escapeHtml } from './escapeHtml.js'; +import { inlineToHtml } from './inlineToHtml.js'; + +export function markdownToHtml(markdown) { + const lines = markdown.replace(/\r\n?/g, '\n').split('\n'); + const html = []; + const paragraph = []; + let listType = null; + let codeLines = null; + + const flushParagraph = () => { + if (paragraph.length === 0) { + return; + } + + html.push(`

${inlineToHtml(paragraph.join(' '))}

`); + paragraph.length = 0; + }; + + const closeList = () => { + if (!listType) { + return; + } + + html.push(``); + listType = null; + }; + + for (const line of lines) { + if (/^```/.test(line)) { + codeLines = toggleCodeBlock({ codeLines, flushParagraph, closeList, html }); + continue; + } + + if (codeLines) { + codeLines.push(line); + continue; + } + + if (!line.trim()) { + flushParagraph(); + closeList(); + continue; + } + + if (renderHeading({ closeList, flushParagraph, html, line })) { + continue; + } + + if (renderQuote({ closeList, flushParagraph, html, line })) { + continue; + } + + const renderedListType = renderListItem({ closeList, flushParagraph, html, line, listType }); + if (renderedListType) { + listType = renderedListType; + continue; + } + + paragraph.push(line.trim()); + } + + if (codeLines) { + html.push(`
${escapeHtml(codeLines.join('\n'))}
`); + } + + flushParagraph(); + closeList(); + + return html.join('\n'); +} + +function toggleCodeBlock({ closeList, codeLines, flushParagraph, html }) { + if (codeLines) { + html.push(`
${escapeHtml(codeLines.join('\n'))}
`); + return null; + } + + flushParagraph(); + closeList(); + return []; +} + +function renderHeading({ closeList, flushParagraph, html, line }) { + const heading = line.match(/^(#{1,3})\s+(.+)$/); + if (!heading) { + return false; + } + + flushParagraph(); + closeList(); + html.push(`${inlineToHtml(heading[2])}`); + return true; +} + +function renderQuote({ closeList, flushParagraph, html, line }) { + const quote = line.match(/^>\s?(.+)$/); + if (!quote) { + return false; + } + + flushParagraph(); + closeList(); + html.push(`
${inlineToHtml(quote[1])}
`); + return true; +} + +function renderListItem({ closeList, flushParagraph, html, line, listType }) { + const unordered = line.match(/^[-*]\s+(.+)$/); + const ordered = line.match(/^\d+\.\s+(.+)$/); + if (!unordered && !ordered) { + return null; + } + + flushParagraph(); + const nextListType = unordered ? 'ul' : 'ol'; + + if (listType !== nextListType) { + closeList(); + html.push(`<${nextListType}>`); + } + + html.push(`
  • ${inlineToHtml((unordered || ordered)[1])}
  • `); + return nextListType; +} diff --git a/local-admin/public/src/shared/lib/markdown/nodeToMarkdown.js b/local-admin/public/src/shared/lib/markdown/nodeToMarkdown.js new file mode 100644 index 0000000..1e19276 --- /dev/null +++ b/local-admin/public/src/shared/lib/markdown/nodeToMarkdown.js @@ -0,0 +1,73 @@ +export function nodeToMarkdown(node) { + if (node.nodeType === Node.TEXT_NODE) { + return node.textContent.replace(/\s+/g, ' '); + } + + if (node.nodeType !== Node.ELEMENT_NODE) { + return ''; + } + + return elementToMarkdown(node); +} + +function elementToMarkdown(node) { + const tag = node.tagName.toLowerCase(); + const children = () => Array.from(node.childNodes).map(nodeToMarkdown).join('').trim(); + + const renderers = { + a: () => renderLink(node, children), + b: () => `**${children()}**`, + blockquote: () => renderBlockquote(children()), + br: () => '\n', + code: () => renderCode(node), + em: () => `*${children()}*`, + figcaption: () => `\n*${children()}*`, + figure: () => `\n\n${children()}\n\n`, + i: () => `*${children()}*`, + img: () => renderImage(node), + li: () => `- ${children()}\n`, + ol: () => renderList(node), + p: () => `\n\n${children()}\n\n`, + pre: () => `\n\n\`\`\`\n${node.textContent.trim()}\n\`\`\`\n\n`, + strong: () => `**${children()}**`, + ul: () => renderList(node), + }; + + if (/^h[1-6]$/.test(tag)) { + return renderHeading(tag, children()); + } + + return renderers[tag]?.() || children(); +} + +function renderBlockquote(content) { + return `\n\n${content + .split('\n') + .map((line) => (line.trim() ? `> ${line.trim()}` : '>')) + .join('\n')}\n\n`; +} + +function renderCode(node) { + return node.parentElement?.tagName.toLowerCase() === 'pre' ? node.textContent : `\`${node.textContent.trim()}\``; +} + +function renderHeading(tag, content) { + const level = Math.min(Number(tag.slice(1)), 3); + return `\n\n${'#'.repeat(level)} ${content}\n\n`; +} + +function renderImage(node) { + const alt = node.getAttribute('alt') || ''; + const src = node.getAttribute('src') || ''; + return src ? `![${alt}](${src})` : ''; +} + +function renderLink(node, children) { + const text = children() || node.getAttribute('href') || ''; + const href = node.getAttribute('href') || ''; + return href ? `[${text}](${href})` : text; +} + +function renderList(node) { + return `\n${Array.from(node.children).map(nodeToMarkdown).join('')}\n`; +} diff --git a/local-admin/public/src/shared/lib/markdown/normalizeAssetUrl.js b/local-admin/public/src/shared/lib/markdown/normalizeAssetUrl.js new file mode 100644 index 0000000..d00d47e --- /dev/null +++ b/local-admin/public/src/shared/lib/markdown/normalizeAssetUrl.js @@ -0,0 +1,9 @@ +export function normalizeAssetUrl(url) { + const normalized = url.trim(); + + if (/^(https?:|\/|#)/i.test(normalized)) { + return normalized; + } + + return null; +} diff --git a/local-admin/public/src/shared/lib/markdown/normalizeMarkdown.js b/local-admin/public/src/shared/lib/markdown/normalizeMarkdown.js new file mode 100644 index 0000000..a9344fa --- /dev/null +++ b/local-admin/public/src/shared/lib/markdown/normalizeMarkdown.js @@ -0,0 +1,6 @@ +export function normalizeMarkdown(markdown) { + return markdown + .replace(/[ \t]+\n/g, '\n') + .replace(/\n{3,}/g, '\n\n') + .trim(); +} diff --git a/local-admin/public/src/shared/lib/markdown/previewStyles.js b/local-admin/public/src/shared/lib/markdown/previewStyles.js new file mode 100644 index 0000000..f3d416b --- /dev/null +++ b/local-admin/public/src/shared/lib/markdown/previewStyles.js @@ -0,0 +1,13 @@ +export const previewStyles = ` + body { margin: 0; padding: 24px; color: #1e252d; font: 18px/1.65 Arial, Helvetica, sans-serif; } + h1 { margin: 38px 0 14px; font-size: 34px; line-height: 1.15; } + h2 { margin: 36px 0 12px; font-size: 30px; line-height: 1.2; } + h3 { margin: 30px 0 10px; font-size: 24px; line-height: 1.2; } + p, ul, ol, blockquote { margin: 0 0 20px; } + blockquote { border-left: 3px solid #cfd7df; padding-left: 16px; color: #617080; } + a { color: #176b5d; } + img { max-width: 100%; border: 1px solid #d8d0c2; border-radius: 8px; } + pre { overflow-x: auto; border-radius: 8px; padding: 18px 20px; background: #111827; color: #e5e7eb; font-size: 15px; } + code { font-family: SFMono-Regular, Menlo, Consolas, monospace; } + :not(pre) > code { border-radius: 4px; padding: 2px 5px; background: #eef1f4; } +`; diff --git a/local-admin/public/src/shared/lib/slug.js b/local-admin/public/src/shared/lib/slug.js new file mode 100644 index 0000000..4c37e7f --- /dev/null +++ b/local-admin/public/src/shared/lib/slug.js @@ -0,0 +1,24 @@ +export function slugify(value) { + return ( + value + .toLowerCase() + .trim() + .replace(/ё/g, 'е') + .replace(/[^a-zа-я0-9]+/giu, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 90) || `post-${Date.now()}` + ); +} + +export function uniqueDraftSlug(slug, articles) { + const used = new Set(articles.map((article) => article.slug)); + let candidate = slug; + let index = 2; + + while (used.has(candidate)) { + candidate = `${slug}-${index}`; + index += 1; + } + + return candidate; +} diff --git a/local-admin/public/src/widgets/article-list/ui.js b/local-admin/public/src/widgets/article-list/ui.js new file mode 100644 index 0000000..40c80bb --- /dev/null +++ b/local-admin/public/src/widgets/article-list/ui.js @@ -0,0 +1,39 @@ +import { formatDate } from '../../shared/lib/date.js'; + +export function renderArticleList({ articles, container, onSelect, query, selectedSlug }) { + const normalizedQuery = query.trim().toLowerCase(); + const visibleArticles = articles.filter((article) => { + if (!normalizedQuery) { + return true; + } + + return [article.title, article.slug, article.author, ...(article.categories || [])] + .join(' ') + .toLowerCase() + .includes(normalizedQuery); + }); + + if (visibleArticles.length === 0) { + container.innerHTML = '
    Статей не найдено
    '; + return; + } + + container.replaceChildren( + ...visibleArticles.map((article) => { + const button = document.createElement('button'); + button.className = 'article-item'; + button.type = 'button'; + button.setAttribute('aria-selected', String(article.slug === selectedSlug)); + button.addEventListener('click', () => onSelect(article.slug)); + + const title = document.createElement('strong'); + title.textContent = article.title; + + const meta = document.createElement('span'); + meta.textContent = `${formatDate(article.date)} · ${article.slug}`; + + button.append(title, meta); + return button; + }), + ); +} diff --git a/local-admin/public/styles.css b/local-admin/public/styles.css new file mode 100644 index 0000000..0458ec1 --- /dev/null +++ b/local-admin/public/styles.css @@ -0,0 +1,386 @@ +:root { + --bg: #eef1f4; + --panel: #ffffff; + --ink: #1f2933; + --muted: #617080; + --line: #cfd7df; + --accent: #176b5d; + --accent-strong: #0d5549; + --danger: #b42318; + --danger-bg: #fff1f0; + --shadow: 0 18px 45px rgba(31, 41, 51, 0.1); +} + +* { + box-sizing: border-box; +} + +html, +body { + min-height: 100%; +} + +body { + margin: 0; + background: var(--bg); + color: var(--ink); + font-family: Arial, Helvetica, sans-serif; + line-height: 1.5; +} + +button, +input, +textarea { + font: inherit; +} + +.topbar { + min-height: 92px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 24px; + padding: 18px 28px; + border-bottom: 1px solid var(--line); + background: #f9fafb; +} + +.topbar h1 { + margin: 2px 0 0; + font-size: 28px; + line-height: 1.15; +} + +.eyebrow { + margin: 0; + color: var(--accent); + font-size: 12px; + font-weight: 700; + letter-spacing: 0; + text-transform: uppercase; +} + +.topbar__actions, +.editor-tools { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; +} + +.button { + min-height: 38px; + border: 1px solid var(--line); + border-radius: 6px; + padding: 8px 13px; + background: #ffffff; + color: var(--ink); + cursor: pointer; +} + +.button:hover { + border-color: #9aa8b6; +} + +.button:disabled { + cursor: not-allowed; + opacity: 0.55; +} + +.button--primary { + border-color: var(--accent-strong); + background: var(--accent); + color: #ffffff; +} + +.button--danger { + border-color: #f0b8b2; + background: var(--danger-bg); + color: var(--danger); +} + +.workspace { + height: calc(100vh - 92px); + display: grid; + grid-template-columns: minmax(280px, 360px) minmax(0, 1fr); +} + +.sidebar { + min-height: 0; + border-right: 1px solid var(--line); + background: #f8fafc; + overflow: hidden; + display: flex; + flex-direction: column; +} + +.sidebar__header { + padding: 18px; + border-bottom: 1px solid var(--line); +} + +.sidebar__header label, +.field span { + display: block; + margin-bottom: 6px; + color: var(--muted); + font-size: 13px; + font-weight: 700; +} + +input, +textarea { + width: 100%; + border: 1px solid var(--line); + border-radius: 6px; + background: #ffffff; + color: var(--ink); + outline: none; +} + +input { + min-height: 38px; + padding: 8px 10px; +} + +textarea { + resize: vertical; + padding: 10px; +} + +.field-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 10px; +} + +input:focus, +textarea:focus { + border-color: var(--accent); + box-shadow: 0 0 0 3px rgba(23, 107, 93, 0.14); +} + +.article-list { + min-height: 0; + overflow: auto; + padding: 10px; +} + +.article-list__empty { + padding: 18px 8px; + color: var(--muted); +} + +.article-item { + width: 100%; + display: block; + border: 1px solid transparent; + border-radius: 6px; + padding: 10px; + background: transparent; + text-align: left; +} + +.article-item:hover { + background: #eef4f6; +} + +.article-item[aria-selected='true'] { + border-color: rgba(23, 107, 93, 0.38); + background: #e5f3f0; +} + +.article-item strong { + display: block; + line-height: 1.25; +} + +.article-item span { + display: block; + margin-top: 4px; + color: var(--muted); + font-size: 13px; +} + +.editor { + min-width: 0; + min-height: 0; + overflow: auto; + padding: 22px 28px 34px; +} + +.status-line { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + margin-bottom: 18px; + color: var(--muted); + font-size: 14px; +} + +.status-line a { + color: var(--accent); + font-weight: 700; + text-decoration: none; +} + +.article-form { + display: grid; + gap: 18px; +} + +.form-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 16px; + padding: 18px; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--panel); + box-shadow: var(--shadow); +} + +.field--wide { + grid-column: 1 / -1; +} + +.editor-tools { + justify-content: flex-start; +} + +.content-layout { + min-height: 680px; + display: grid; + grid-template-columns: minmax(0, 1.08fr) minmax(360px, 0.92fr); + gap: 18px; +} + +.content-field, +.preview { + min-width: 0; + min-height: 0; + display: flex; + flex-direction: column; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--panel); + box-shadow: var(--shadow); +} + +.content-field { + padding: 14px; +} + +.content-field__header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + margin-bottom: 8px; +} + +.content-field__header > span { + margin-bottom: 0; +} + +.markdown-toolbar { + display: flex; + justify-content: flex-end; + gap: 6px; + flex-wrap: wrap; +} + +.tool-button { + min-height: 30px; + border: 1px solid var(--line); + border-radius: 6px; + padding: 5px 8px; + background: #f8fafc; + color: var(--ink); + cursor: pointer; + font-size: 12px; + font-weight: 700; +} + +.tool-button:hover { + border-color: #9aa8b6; + background: #ffffff; +} + +.content-field textarea { + flex: 1; + min-height: 560px; + font-family: SFMono-Regular, Menlo, Consolas, monospace; + font-size: 14px; + line-height: 1.55; +} + +.preview { + overflow: hidden; +} + +.preview__cover { + min-height: 190px; + border-bottom: 1px solid var(--line); + background: #f8fafc; +} + +.preview__cover img { + width: 100%; + height: 260px; + object-fit: cover; +} + +.preview iframe { + width: 100%; + flex: 1; + min-height: 420px; + border: 0; + background: #ffffff; +} + +@media (max-width: 980px) { + .topbar { + align-items: flex-start; + flex-direction: column; + } + + .workspace { + height: auto; + grid-template-columns: 1fr; + } + + .sidebar { + max-height: 360px; + border-right: 0; + border-bottom: 1px solid var(--line); + } + + .content-layout, + .form-grid { + grid-template-columns: 1fr; + } +} + +@media (max-width: 640px) { + .topbar, + .editor { + padding-left: 16px; + padding-right: 16px; + } + + .topbar__actions, + .editor-tools { + width: 100%; + } + + .button { + flex: 1 1 auto; + } + + .status-line { + align-items: flex-start; + flex-direction: column; + } +} diff --git a/local-admin/src/app/requestHandler.mjs b/local-admin/src/app/requestHandler.mjs new file mode 100644 index 0000000..56f8884 --- /dev/null +++ b/local-admin/src/app/requestHandler.mjs @@ -0,0 +1,71 @@ +import { paths } from '../config/paths.mjs'; +import { + handleCreateArticle, + handleDeleteArticle, + handleListArticles, + handleUpdateArticle, +} from '../entities/article/articleHandlers.mjs'; +import { handleListAssets, handleUploadAsset } from '../entities/asset/assetHandlers.mjs'; +import { httpError } from '../shared/http/httpError.mjs'; +import { sendError } from '../shared/http/sendError.mjs'; +import { serveFile } from '../shared/static/serveFile.mjs'; + +export async function handleRequest(request, response) { + try { + return await routeRequest(request, response); + } catch (error) { + return sendError(response, error); + } +} + +async function routeRequest(request, response) { + const url = new URL(request.url || '/', `http://${request.headers.host || 'localhost'}`); + + if (url.pathname === '/api/articles' && request.method === 'GET') { + return await handleListArticles(request, response); + } + + if (url.pathname === '/api/articles' && request.method === 'POST') { + return await handleCreateArticle(request, response); + } + + if (url.pathname === '/api/assets' && request.method === 'GET') { + return await handleListAssets(request, response); + } + + if (url.pathname === '/api/assets' && request.method === 'POST') { + return await handleUploadAsset(request, response); + } + + const articleRoute = url.pathname.match(/^\/api\/articles\/(.+)$/); + if (articleRoute) { + return await routeArticleBySlug(request, response, decodeURIComponent(articleRoute[1])); + } + + return await routeStaticFile(request, response, url.pathname); +} + +async function routeArticleBySlug(request, response, slug) { + if (request.method === 'PUT') { + return await handleUpdateArticle(request, response, slug); + } + + if (request.method === 'DELETE') { + return await handleDeleteArticle(request, response, slug); + } + + throw httpError(405, 'Method not allowed'); +} + +async function routeStaticFile(request, response, pathname) { + if (request.method !== 'GET' && request.method !== 'HEAD') { + throw httpError(405, 'Method not allowed'); + } + + if (pathname.startsWith('/assets/')) { + return await serveFile(response, paths.webPublicRoot, pathname, request.method); + } + + const adminPath = pathname === '/' ? '/index.html' : pathname; + return await serveFile(response, paths.adminPublicRoot, adminPath, request.method); +} diff --git a/local-admin/src/app/startServer.mjs b/local-admin/src/app/startServer.mjs new file mode 100644 index 0000000..ec6ef5c --- /dev/null +++ b/local-admin/src/app/startServer.mjs @@ -0,0 +1,30 @@ +import { createServer } from 'node:http'; +import { paths } from '../config/paths.mjs'; +import { serverConfig } from '../config/server.mjs'; +import { handleRequest } from './requestHandler.mjs'; + +export function startServer() { + const server = createServer(handleRequest); + + server.on('error', (error) => { + if (error.code === 'EADDRINUSE') { + console.error(`Port ${serverConfig.port} is already in use. Run with ADMIN_PORT=3334 npm run admin:posts.`); + process.exit(1); + } + + throw error; + }); + + server.listen(serverConfig.port, serverConfig.host, () => { + console.log(`Local posts admin: http://${serverConfig.host}:${serverConfig.port}`); + console.log(`Editing: ${paths.articlesFile}`); + }); + + for (const signal of ['SIGINT', 'SIGTERM']) { + process.on(signal, () => { + server.close(() => process.exit(0)); + }); + } + + return server; +} diff --git a/local-admin/src/config/limits.mjs b/local-admin/src/config/limits.mjs new file mode 100644 index 0000000..8618c26 --- /dev/null +++ b/local-admin/src/config/limits.mjs @@ -0,0 +1,4 @@ +export const requestLimits = { + maxBodySize: 30 * 1024 * 1024, + maxUploadSize: 20 * 1024 * 1024, +}; diff --git a/local-admin/src/config/paths.mjs b/local-admin/src/config/paths.mjs new file mode 100644 index 0000000..0088bec --- /dev/null +++ b/local-admin/src/config/paths.mjs @@ -0,0 +1,13 @@ +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const adminRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..'); +const repoRoot = resolve(adminRoot, '..'); +const webRoot = resolve(repoRoot, 'web'); + +export const paths = { + adminPublicRoot: resolve(adminRoot, 'public'), + articlesFile: resolve(webRoot, 'data', 'articles.json'), + uploadRoot: resolve(webRoot, 'public', 'assets', 'uploads'), + webPublicRoot: resolve(webRoot, 'public'), +}; diff --git a/local-admin/src/config/server.mjs b/local-admin/src/config/server.mjs new file mode 100644 index 0000000..e98ed2c --- /dev/null +++ b/local-admin/src/config/server.mjs @@ -0,0 +1,4 @@ +export const serverConfig = { + host: process.env.ADMIN_HOST || '127.0.0.1', + port: Number(process.env.ADMIN_PORT || 3333), +}; diff --git a/local-admin/src/entities/article/articleHandlers.mjs b/local-admin/src/entities/article/articleHandlers.mjs new file mode 100644 index 0000000..09c2354 --- /dev/null +++ b/local-admin/src/entities/article/articleHandlers.mjs @@ -0,0 +1,4 @@ +export { handleCreateArticle } from './handleCreateArticle.mjs'; +export { handleDeleteArticle } from './handleDeleteArticle.mjs'; +export { handleListArticles } from './handleListArticles.mjs'; +export { handleUpdateArticle } from './handleUpdateArticle.mjs'; diff --git a/local-admin/src/entities/article/articleRepository.mjs b/local-admin/src/entities/article/articleRepository.mjs new file mode 100644 index 0000000..920bc71 --- /dev/null +++ b/local-admin/src/entities/article/articleRepository.mjs @@ -0,0 +1,20 @@ +import { readFile, rename, writeFile } from 'node:fs/promises'; +import { paths } from '../../config/paths.mjs'; +import { httpError } from '../../shared/http/httpError.mjs'; + +export async function readArticles() { + const raw = await readFile(paths.articlesFile, 'utf8'); + const articles = JSON.parse(raw); + + if (!Array.isArray(articles)) { + throw httpError(500, 'Article database must be a JSON array'); + } + + return articles; +} + +export async function writeArticles(articles) { + const temporaryFile = `${paths.articlesFile}.${process.pid}.tmp`; + await writeFile(temporaryFile, `${JSON.stringify(articles, null, 2)}\n`, 'utf8'); + await rename(temporaryFile, paths.articlesFile); +} diff --git a/local-admin/src/entities/article/articleValidator.mjs b/local-admin/src/entities/article/articleValidator.mjs new file mode 100644 index 0000000..39edebe --- /dev/null +++ b/local-admin/src/entities/article/articleValidator.mjs @@ -0,0 +1,78 @@ +import { httpError } from '../../shared/http/httpError.mjs'; + +export function normalizeArticle(input) { + if (!input || typeof input !== 'object' || Array.isArray(input)) { + throw httpError(400, 'Article payload must be an object'); + } + + const article = { + ...input, + slug: readRequiredString(input, 'slug'), + title: readRequiredString(input, 'title'), + date: readRequiredString(input, 'date'), + author: readRequiredString(input, 'author'), + categories: normalizeCategories(input.categories), + cover: readRequiredString(input, 'cover'), + excerpt: readRequiredString(input, 'excerpt'), + contentHtml: readRequiredString(input, 'contentHtml', { preserveWhitespace: true }), + readingMinutes: normalizeReadingMinutes(input.readingMinutes), + }; + + if (/[/?#]/.test(article.slug)) { + throw httpError(400, 'Slug must not contain "/", "?" or "#"'); + } + + if (Number.isNaN(new Date(article.date).getTime())) { + throw httpError(400, 'Date must be a valid date string'); + } + + return article; +} + +export function assertUniqueSlug(articles, slug, exceptSlug) { + const duplicate = articles.find((article) => article.slug === slug && article.slug !== exceptSlug); + + if (duplicate) { + throw httpError(409, `Slug "${slug}" is already used`); + } +} + +function readRequiredString(input, key, options = {}) { + const value = input[key]; + + if (typeof value !== 'string') { + throw httpError(400, `"${key}" must be a string`); + } + + const normalized = options.preserveWhitespace ? value : value.trim(); + if (!normalized) { + throw httpError(400, `"${key}" is required`); + } + + return normalized; +} + +function normalizeCategories(value) { + const categories = Array.isArray(value) + ? value + : String(value || '') + .split(',') + .map((category) => category.trim()); + + const normalized = categories.filter(Boolean); + if (normalized.length === 0) { + throw httpError(400, 'At least one category is required'); + } + + return [...new Set(normalized)]; +} + +function normalizeReadingMinutes(value) { + const minutes = Number(value); + + if (!Number.isInteger(minutes) || minutes < 1) { + throw httpError(400, '"readingMinutes" must be a positive integer'); + } + + return minutes; +} diff --git a/local-admin/src/entities/article/handleCreateArticle.mjs b/local-admin/src/entities/article/handleCreateArticle.mjs new file mode 100644 index 0000000..4c40403 --- /dev/null +++ b/local-admin/src/entities/article/handleCreateArticle.mjs @@ -0,0 +1,16 @@ +import { readJsonBody } from '../../shared/http/readJsonBody.mjs'; +import { sendJson } from '../../shared/http/sendJson.mjs'; +import { readArticles, writeArticles } from './articleRepository.mjs'; +import { assertUniqueSlug, normalizeArticle } from './articleValidator.mjs'; + +export async function handleCreateArticle(request, response) { + const payload = await readJsonBody(request); + const article = normalizeArticle(payload.article || payload); + const articles = await readArticles(); + + assertUniqueSlug(articles, article.slug); + articles.unshift(article); + await writeArticles(articles); + + return sendJson(response, { article }, 201); +} diff --git a/local-admin/src/entities/article/handleDeleteArticle.mjs b/local-admin/src/entities/article/handleDeleteArticle.mjs new file mode 100644 index 0000000..4382802 --- /dev/null +++ b/local-admin/src/entities/article/handleDeleteArticle.mjs @@ -0,0 +1,17 @@ +import { httpError } from '../../shared/http/httpError.mjs'; +import { sendJson } from '../../shared/http/sendJson.mjs'; +import { readArticles, writeArticles } from './articleRepository.mjs'; + +export async function handleDeleteArticle(_request, response, slug) { + const articles = await readArticles(); + const index = articles.findIndex((article) => article.slug === slug); + + if (index === -1) { + throw httpError(404, `Article "${slug}" was not found`); + } + + const [article] = articles.splice(index, 1); + await writeArticles(articles); + + return sendJson(response, { article }); +} diff --git a/local-admin/src/entities/article/handleListArticles.mjs b/local-admin/src/entities/article/handleListArticles.mjs new file mode 100644 index 0000000..8ca9d36 --- /dev/null +++ b/local-admin/src/entities/article/handleListArticles.mjs @@ -0,0 +1,6 @@ +import { sendJson } from '../../shared/http/sendJson.mjs'; +import { readArticles } from './articleRepository.mjs'; + +export async function handleListArticles(_request, response) { + return sendJson(response, { articles: await readArticles() }); +} diff --git a/local-admin/src/entities/article/handleUpdateArticle.mjs b/local-admin/src/entities/article/handleUpdateArticle.mjs new file mode 100644 index 0000000..9a89c6c --- /dev/null +++ b/local-admin/src/entities/article/handleUpdateArticle.mjs @@ -0,0 +1,22 @@ +import { httpError } from '../../shared/http/httpError.mjs'; +import { readJsonBody } from '../../shared/http/readJsonBody.mjs'; +import { sendJson } from '../../shared/http/sendJson.mjs'; +import { readArticles, writeArticles } from './articleRepository.mjs'; +import { assertUniqueSlug, normalizeArticle } from './articleValidator.mjs'; + +export async function handleUpdateArticle(request, response, currentSlug) { + const payload = await readJsonBody(request); + const articles = await readArticles(); + const index = articles.findIndex((article) => article.slug === currentSlug); + + if (index === -1) { + throw httpError(404, `Article "${currentSlug}" was not found`); + } + + const article = normalizeArticle(payload.article || payload); + assertUniqueSlug(articles, article.slug, currentSlug); + articles[index] = article; + await writeArticles(articles); + + return sendJson(response, { article }); +} diff --git a/local-admin/src/entities/asset/assetHandlers.mjs b/local-admin/src/entities/asset/assetHandlers.mjs new file mode 100644 index 0000000..29c16ba --- /dev/null +++ b/local-admin/src/entities/asset/assetHandlers.mjs @@ -0,0 +1,2 @@ +export { handleListAssets } from './handleListAssets.mjs'; +export { handleUploadAsset } from './handleUploadAsset.mjs'; diff --git a/local-admin/src/entities/asset/assetTypes.mjs b/local-admin/src/entities/asset/assetTypes.mjs new file mode 100644 index 0000000..eaf0539 --- /dev/null +++ b/local-admin/src/entities/asset/assetTypes.mjs @@ -0,0 +1,20 @@ +export const imageExtensions = new Set(['.avif', '.gif', '.jpg', '.jpeg', '.png', '.svg', '.webp']); + +export const assetExtensions = new Set([ + ...imageExtensions, + '.css', + '.csv', + '.doc', + '.docx', + '.json', + '.md', + '.mp3', + '.mp4', + '.ogg', + '.pdf', + '.txt', + '.webm', + '.xls', + '.xlsx', + '.zip', +]); diff --git a/local-admin/src/entities/asset/getUniqueFileName.mjs b/local-admin/src/entities/asset/getUniqueFileName.mjs new file mode 100644 index 0000000..d45f154 --- /dev/null +++ b/local-admin/src/entities/asset/getUniqueFileName.mjs @@ -0,0 +1,24 @@ +import { stat } from 'node:fs/promises'; +import { extname, resolve } from 'node:path'; +import { httpError } from '../../shared/http/httpError.mjs'; + +export async function getUniqueFileName(root, fileName) { + const extension = extname(fileName); + const baseName = extension ? fileName.slice(0, -extension.length) : fileName; + + for (let index = 0; index < 1000; index += 1) { + const candidate = index === 0 ? fileName : `${baseName}-${index + 1}${extension}`; + + try { + await stat(resolve(root, candidate)); + } catch (error) { + if (error.code === 'ENOENT') { + return candidate; + } + + throw error; + } + } + + throw httpError(500, 'Could not pick a unique file name'); +} diff --git a/local-admin/src/entities/asset/handleListAssets.mjs b/local-admin/src/entities/asset/handleListAssets.mjs new file mode 100644 index 0000000..0250295 --- /dev/null +++ b/local-admin/src/entities/asset/handleListAssets.mjs @@ -0,0 +1,8 @@ +import { resolve } from 'node:path'; +import { paths } from '../../config/paths.mjs'; +import { sendJson } from '../../shared/http/sendJson.mjs'; +import { listAssets } from './listAssets.mjs'; + +export async function handleListAssets(_request, response) { + return sendJson(response, { assets: await listAssets(resolve(paths.webPublicRoot, 'assets'), '/assets') }); +} diff --git a/local-admin/src/entities/asset/handleUploadAsset.mjs b/local-admin/src/entities/asset/handleUploadAsset.mjs new file mode 100644 index 0000000..55ea5fb --- /dev/null +++ b/local-admin/src/entities/asset/handleUploadAsset.mjs @@ -0,0 +1,62 @@ +import { mkdir, writeFile } from 'node:fs/promises'; +import { extname, resolve } from 'node:path'; +import { requestLimits } from '../../config/limits.mjs'; +import { paths } from '../../config/paths.mjs'; +import { httpError } from '../../shared/http/httpError.mjs'; +import { readJsonBody } from '../../shared/http/readJsonBody.mjs'; +import { sendJson } from '../../shared/http/sendJson.mjs'; +import { assetExtensions, imageExtensions } from './assetTypes.mjs'; +import { getUniqueFileName } from './getUniqueFileName.mjs'; +import { parseUploadPayload } from './parseUploadPayload.mjs'; +import { sanitizeFileName } from './sanitizeFileName.mjs'; + +export async function handleUploadAsset(request, response) { + const payload = await readJsonBody(request); + const fileName = sanitizeFileName(readRequiredString(payload, 'fileName')); + const extension = extname(fileName).toLowerCase(); + + validateExtension(extension); + const fileBuffer = parseUploadPayload(payload); + validateUploadSize(fileBuffer.length); + + await mkdir(paths.uploadRoot, { recursive: true }); + const uniqueFileName = await getUniqueFileName(paths.uploadRoot, fileName); + await writeFile(resolve(paths.uploadRoot, uniqueFileName), fileBuffer); + + return sendJson(response, { asset: createAssetResponse(uniqueFileName, extension, fileBuffer.length) }, 201); +} + +function createAssetResponse(fileName, extension, size) { + return { + fileName, + isImage: imageExtensions.has(extension), + path: `/assets/uploads/${fileName}`, + size, + }; +} + +function readRequiredString(input, key) { + const value = input[key]; + + if (typeof value !== 'string' || !value.trim()) { + throw httpError(400, `"${key}" is required`); + } + + return value.trim(); +} + +function validateExtension(extension) { + if (!assetExtensions.has(extension)) { + throw httpError(400, `Files with "${extension || 'no'}" extension are not allowed`); + } +} + +function validateUploadSize(size) { + if (size === 0) { + throw httpError(400, 'Uploaded file is empty'); + } + + if (size > requestLimits.maxUploadSize) { + throw httpError(413, `Uploaded file must be ${Math.round(requestLimits.maxUploadSize / 1024 / 1024)} MB or smaller`); + } +} diff --git a/local-admin/src/entities/asset/listAssets.mjs b/local-admin/src/entities/asset/listAssets.mjs new file mode 100644 index 0000000..e0bdc2d --- /dev/null +++ b/local-admin/src/entities/asset/listAssets.mjs @@ -0,0 +1,24 @@ +import { readdir } from 'node:fs/promises'; +import { extname, resolve } from 'node:path'; +import { imageExtensions } from './assetTypes.mjs'; + +export async function listAssets(root, publicPrefix) { + const entries = await readdir(root, { withFileTypes: true }); + const assets = []; + + for (const entry of entries) { + const filePath = resolve(root, entry.name); + const assetPath = `${publicPrefix}/${entry.name}`; + + if (entry.isDirectory()) { + assets.push(...(await listAssets(filePath, assetPath))); + continue; + } + + if (entry.isFile() && imageExtensions.has(extname(entry.name).toLowerCase())) { + assets.push(assetPath); + } + } + + return assets.sort((left, right) => left.localeCompare(right, 'ru')); +} diff --git a/local-admin/src/entities/asset/parseUploadPayload.mjs b/local-admin/src/entities/asset/parseUploadPayload.mjs new file mode 100644 index 0000000..1e33f07 --- /dev/null +++ b/local-admin/src/entities/asset/parseUploadPayload.mjs @@ -0,0 +1,19 @@ +import { httpError } from '../../shared/http/httpError.mjs'; + +export function parseUploadPayload(payload) { + if (typeof payload.dataUrl === 'string') { + const match = payload.dataUrl.match(/^data:[^;]+;base64,(.+)$/); + + if (!match) { + throw httpError(400, '"dataUrl" must be a base64 data URL'); + } + + return Buffer.from(match[1], 'base64'); + } + + if (typeof payload.contentBase64 === 'string') { + return Buffer.from(payload.contentBase64, 'base64'); + } + + throw httpError(400, 'Upload payload must include "dataUrl" or "contentBase64"'); +} diff --git a/local-admin/src/entities/asset/sanitizeFileName.mjs b/local-admin/src/entities/asset/sanitizeFileName.mjs new file mode 100644 index 0000000..067a909 --- /dev/null +++ b/local-admin/src/entities/asset/sanitizeFileName.mjs @@ -0,0 +1,17 @@ +import { extname } from 'node:path'; + +export function sanitizeFileName(fileName) { + const extension = extname(fileName).toLowerCase(); + const rawBaseName = fileName.slice(0, extension ? -extension.length : undefined); + const baseName = + rawBaseName + .normalize('NFKD') + .trim() + .replace(/^\.+/, '') + .replace(/[^\p{L}\p{N}._-]+/gu, '-') + .replace(/-+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 90) || 'asset'; + + return `${baseName}${extension}`; +} diff --git a/local-admin/src/shared/http/httpError.mjs b/local-admin/src/shared/http/httpError.mjs new file mode 100644 index 0000000..8ee7476 --- /dev/null +++ b/local-admin/src/shared/http/httpError.mjs @@ -0,0 +1,5 @@ +export function httpError(status, message) { + const error = new Error(message); + error.status = status; + return error; +} diff --git a/local-admin/src/shared/http/readJsonBody.mjs b/local-admin/src/shared/http/readJsonBody.mjs new file mode 100644 index 0000000..2265719 --- /dev/null +++ b/local-admin/src/shared/http/readJsonBody.mjs @@ -0,0 +1,27 @@ +import { requestLimits } from '../../config/limits.mjs'; +import { httpError } from './httpError.mjs'; + +export async function readJsonBody(request) { + const chunks = []; + let bodySize = 0; + + for await (const chunk of request) { + bodySize += chunk.length; + + if (bodySize > requestLimits.maxBodySize) { + throw httpError(413, 'Request body is too large'); + } + + chunks.push(chunk); + } + + if (chunks.length === 0) { + throw httpError(400, 'Request body is empty'); + } + + try { + return JSON.parse(Buffer.concat(chunks).toString('utf8')); + } catch { + throw httpError(400, 'Request body must be valid JSON'); + } +} diff --git a/local-admin/src/shared/http/sendError.mjs b/local-admin/src/shared/http/sendError.mjs new file mode 100644 index 0000000..bbd58d2 --- /dev/null +++ b/local-admin/src/shared/http/sendError.mjs @@ -0,0 +1,12 @@ +import { sendJson } from './sendJson.mjs'; + +export function sendError(response, error) { + const status = error.status || 500; + const message = status >= 500 ? 'Internal server error' : error.message; + + if (status >= 500) { + console.error(error); + } + + return sendJson(response, { error: message }, status); +} diff --git a/local-admin/src/shared/http/sendJson.mjs b/local-admin/src/shared/http/sendJson.mjs new file mode 100644 index 0000000..592c03e --- /dev/null +++ b/local-admin/src/shared/http/sendJson.mjs @@ -0,0 +1,10 @@ +export function sendJson(response, body, status = 200) { + const payload = `${JSON.stringify(body)}\n`; + response.writeHead(status, { + 'Content-Length': Buffer.byteLength(payload), + 'Content-Type': 'application/json; charset=utf-8', + 'Cache-Control': 'no-store', + 'X-Content-Type-Options': 'nosniff', + }); + response.end(payload); +} diff --git a/local-admin/src/shared/static/mimeTypes.mjs b/local-admin/src/shared/static/mimeTypes.mjs new file mode 100644 index 0000000..8f2f8e2 --- /dev/null +++ b/local-admin/src/shared/static/mimeTypes.mjs @@ -0,0 +1,25 @@ +export const mimeTypes = { + '.csv': 'text/csv; charset=utf-8', + '.css': 'text/css; charset=utf-8', + '.doc': 'application/msword', + '.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + '.gif': 'image/gif', + '.html': 'text/html; charset=utf-8', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.js': 'text/javascript; charset=utf-8', + '.json': 'application/json; charset=utf-8', + '.md': 'text/markdown; charset=utf-8', + '.mp3': 'audio/mpeg', + '.mp4': 'video/mp4', + '.ogg': 'audio/ogg', + '.pdf': 'application/pdf', + '.png': 'image/png', + '.svg': 'image/svg+xml; charset=utf-8', + '.txt': 'text/plain; charset=utf-8', + '.webm': 'video/webm', + '.webp': 'image/webp', + '.xls': 'application/vnd.ms-excel', + '.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + '.zip': 'application/zip', +}; diff --git a/local-admin/src/shared/static/resolveInside.mjs b/local-admin/src/shared/static/resolveInside.mjs new file mode 100644 index 0000000..8029388 --- /dev/null +++ b/local-admin/src/shared/static/resolveInside.mjs @@ -0,0 +1,13 @@ +import { resolve, sep } from 'node:path'; + +export function resolveInside(root, pathname) { + let decodedPath; + try { + decodedPath = decodeURIComponent(pathname); + } catch { + return null; + } + + const filePath = resolve(root, decodedPath.replace(/^\/+/, '')); + return filePath === root || filePath.startsWith(`${root}${sep}`) ? filePath : null; +} diff --git a/local-admin/src/shared/static/serveFile.mjs b/local-admin/src/shared/static/serveFile.mjs new file mode 100644 index 0000000..1977843 --- /dev/null +++ b/local-admin/src/shared/static/serveFile.mjs @@ -0,0 +1,44 @@ +import { createReadStream } from 'node:fs'; +import { stat } from 'node:fs/promises'; +import { extname } from 'node:path'; +import { httpError } from '../http/httpError.mjs'; +import { mimeTypes } from './mimeTypes.mjs'; +import { resolveInside } from './resolveInside.mjs'; + +export async function serveFile(response, root, pathname, method) { + const filePath = resolveInside(root, pathname); + + if (!filePath) { + throw httpError(403, 'Forbidden'); + } + + const fileStat = await getFileStat(filePath); + + response.writeHead(200, { + 'Content-Length': fileStat.size, + 'Content-Type': mimeTypes[extname(filePath).toLowerCase()] || 'application/octet-stream', + 'X-Content-Type-Options': 'nosniff', + }); + + if (method === 'HEAD') { + response.end(); + return; + } + + createReadStream(filePath).pipe(response); +} + +async function getFileStat(filePath) { + let fileStat; + try { + fileStat = await stat(filePath); + } catch { + throw httpError(404, 'Not found'); + } + + if (!fileStat.isFile()) { + throw httpError(404, 'Not found'); + } + + return fileStat; +} diff --git a/web/data/articles.json b/web/data/articles.json index 47fe7b0..656a93a 100644 --- a/web/data/articles.json +++ b/web/data/articles.json @@ -37,7 +37,7 @@ ], "cover": "/assets/illustrations/tilix-cover.svg", "excerpt": "Йоаким — интервьюер-резидент блога о D. Он также брал интервью у членов D-сообщества для This Week in D и ответственен за портирование LDC для Android . Геральд Нанн — разработчик Tilix (ранее называвшийся Terminix). Tilix— продвинутый тайлинговый эмулятор тер", - "contentHtml": "

    Йоаким — интервьюер-резидент блога о D. Он также брал интервью у членов D-сообщества для  This Week in D и ответственен за портирование LDC для Android.

    \n

    Геральд Нанн — разработчик Tilix (ранее называвшийся  Terminix). Tilix— продвинутый тайлинговый эмулятор терминалов с открытым исходным кодом, который является самым «звёздным»  проектом на основе D на GitHub, недавно даже обогнавший стандартный компилятор D, DMD. В этом году на DConf в Берлине он рассказывал о том, как использует D. Имеются слайды и видео. В своей повседневной работе, которая не имеет ничего общего с настольными графическими приложениями, он является старшим разработчиком промежуточных решений в Red Hat. [Подробнее об истории Джеральда в расширенном интервью — Ред.]

    \n

    Йоаким: — Что такое тайлинговый эмулятор терминала?

    \n

    Геральд: — Тайлинговый эмулятор терминала позволяет разделить терминал на несколько частей и распределить их в удобном порядке и месте, что наиболее удобно при работе над конкретной задачей. Люди, которые работают на нескольких терминалах одновременно, как правило, находят такие инструменты наиболее полезными, особенно с постоянно увеличивающимися размерами мониторов и разрешений.

    \n

    Несмотря на то, что Tilix очень хорош, основной причиной, по которой я создал его, было то, что мне нужен был эмулятор терминала, который следовал бы концепции Gnome HIG (Human Interface Guidelines — рекомендации по созданию интуитивных, легко изучаемых и логичных интерфейсов взаимодействия с пользователем) и использовал CSD (Client-Side Decorations — отрисовку на стороне клиента). Tilix следует Gnome HIG, опубликованным здесь, что означает соблюдение интервалов, макетов и других рекомендаций. После HIG важно, чтобы приложение соответствовало всему рабочему столу в целом.

    \n

    CSD ссылается на заголовок окна, где не диспетчер дисплея, а пользователь берёт на себя ответственность за него и может заполнять панель заголовка кнопками и другими элементами управления. Это часть концепции Gnome HIG, и большинство приложений Gnome (gedit, файлы, видео) по умолчанию используют этот подход. Единственное исключением является gnome-terminal, который вообще не использует CSD.

    \n

    Й.: — Можете привести несколько примеров того, как вы используете концепцию Gnome HIG?

    \n

    Г.: — Gnome HIG определяет конкретный язык дизайна в отношении того, как приложения, работающие в Gnome, должны выглядеть и показывать себя. Некоторые примеры Tilix, следующие концепции HIG, включают использование CSD и меню приложений по рекомендациям, таким как интервалы, макеты и т.д. Кроме того, разработчики Gnome собрали множество макетов, как они думают, как должны выглядеть различные приложения. Tilix использует макеты, разработанные дизайнерами Gnome для терминала, где это возможно. Например, в Tilix диалог предпочтений и профилей использовался отдельно, однако один из дизайнеров Gnome предложил этот макет для gnome-терминала. Я пошел вперед и реализовал его в Tilix, гораздо лучше, чем раньше.

    \n

    В результате использования CSD и концепции Gnome HIG, надеюсь, что использование Tilix в Gnome более органичено для пользователей.

    \n

    Интересная вещь — это напряженность между людьми, которые используют Tilix на Gnome и тех, кто использует его в других дистрибутивах. Хотя я не имею никаких сомнений в том, что разработка под Gnome является моей основной целью, я стараюсь сделать Tilix лучше и в других средах рабочего стола, разрешив пользователю отключить CSD в пользу обычного заголовка, если они того пожелают.

    \n

    Й.: — Вы попали в D из среды Java. Вы все еще пишете код в стиле Java на D? Это было легко, т.е. сколько вам пришлось привыкать, чтобы писать на D?

    \n

    Г.: — Да, чаще всего работаю с Java. Я нашёл это довольно интересным на DConf, когда я спросил, как много людей пришли не из среды C/C++ , только один человек поднял руку.

    \n

    Если вы посмотрите на мой код в Tilix, то он очень похож на Java-код. Некоторое из этого связано с моей обычное средой, в которой я работаю, а некоторое из-за того, что GtkD является оболочкой классов.

    \n

    Я считаю, что переключение между D и Java является довольно плавным по большей части; между ними гораздо меньше когнитивного трения, чем, скажем, между переключением между Java и Python.

    \n

    Самая важная идиома D, которую мне пришлось изучить, — это диапазоны, поскольку они являются основополагающей особенностью D. Однако это не было сильно сложным.  Выполнение функций времени компиляции (CTFE) по-прежнему остаются для меня неестественными. Когда я использую их, мне приходится каждый раз искать про них информацию, и мои текущие попытки использования CTFE с точки зрения кода довольны глупы. Я хотел бы больше использовать CTFE в Tilix, поскольку я получаю всё больший опыт использования D.

    \n

    Наконец, мой недостаток опыта работы с C выявляет другую проблему, с которой я немного борюсь — это взаимодействие с кодом на C. Хотя по большей части это довольно просто, но, когда мне приходится расшифровывать что-то сложное, оно становится не таким простым. Поддержка FlatPak (система изолированных контейнеров для графических приложений) в настоящее время есть, так как мне не приходилось так сильно напрягаться по некоторым вопросам, с которыми я сталкивался на C.

    \n

    Сказав это, у меня есть некоторый опыт разработки собственного кода, поскольку много лет назад я потратил много времени на разработку кода на Delphi и Object Pascal. Именно на это и приходится большая часть моего опыта работы с графическим интерфейсом.

    \n

    Й.: — И из вашего выступления на DConf вы явное не беспокоились о сборщике мусора (GC). Вам приходилось думать о нём, когда вы разрабатывали Tilix? Имелись ли проблемы задержек с GUI, вызванные GC?

    \n

    Г.: — Придя из Java, GC для меня довольно естественен, и я определенно не считаю его плохим для D. Я думаю, что GC в D получает много плохой прессы на основе опыта Java, но важно помнить, что GC в D сильно отличается от того, что в Java. Самое большое различие для меня в том, что в D есть больше возможностей для его управления, так как он хорошо понимает, когда он может начать цикл GC. Я был очень рад видеть, как больше людей поднимают различные темы на reddit и форумах, жалуются на использование GC.

    \n

    У меня не было никаких проблем с GC с точки зрения пауз, и ни один пользователь Tilix не сообщал об этом. У меня было несколько проблем, связанных с GC, в основном связанных с утечкой памяти из-за хранения ссылок, но все они были ошибками программного кода, а не проблемой с реализацией GC в D. У меня есть другое приложение на GTK D, Visual Grep, где я столкнулся с ужасающей эффективностью при обработке большого количества совпадений в tight-цикле (цикл, который содержит несколько инструкций и повторяется много раз.). Тем не менее, просто отключив GC для этого раздела кода, ускорилось все.

    \n

    Й.: — Репозиторий github для Tilix замечательно чист, нет открытых запросов «pull request» (PR) и низкий процент проблем, которые все еще открыты. Сколько времени вы еженедельно тратите на Tilix? Является ли Tilix только хобби или он стал чем-то большим?

    \n

    Г.: — Tilix — это просто хобби. Я, вероятно, провожу от 5 до 10 часов в неделю. На данный момент — это зрелое приложение, следовательно, относительно небольшое количество проблем. Я также уделяю приоритетное внимание исправлению ошибок при добавлении новых функций, что помогает сохранить список управляемым.

    \n

    Что касается запросов на «pull request» (PR), я твердо убежден в том, что я должен быть отзывчивым, поэтому я, как правило, отвечаю на PR в течение дня или двух. В качестве участника разработок я знаю, что ничего не убивает интерес, как видеть, что ваш PR томится в течение нескольких недель, месяцев или даже лет. Если вы хотите, чтобы люди вносили свой вклад, что я определенно делаю, то я чувствую, что вы обязаны участникам разработки своевременно реагировать на их PR.

    \n

    На данный момент Tiltx — это относительно небольшой проект, поэтому мне легко принять этот подход. Я понимаю, почему более крупные проекты могут иметь больше проблем в этой области.

    \n

    Й.: — D имеет много особенностей, насколько хорошо вы их знаете? Вы упомянули, что хотите использовать некоторые из возможностей времени компиляции. Как вы думаете за счёт каких особенностей D и что Tilix выиграет в будущем и как?

    \n

    Г.: — Я не думаю, что знаю это, если честно, помимо основного набора особенностей, которые я использую в Tilix. Я всегда удивляюсь ребятам на форуме, которые могут утверждать достоинства/недостатки низкоуровневых деталей языка. Это определенно не я. Я бы хотел поправиться, но на самом деле я использую D только как хобби, поэтому я не могу тратить столько же времени, сколько и на Java. Кроме того, моя работа в Red Hat имеет больше компонентов инфраструктуры, чем мои предыдущие рабочие места, поэтому большая часть моего времени расходуется дома на обучение в этом направлении.

    \n

    Что касается возможностей, от которых Tilix выиграет, я думаю, что использование CTFE и диапазонов было бы очень полезно для того, чтобы улучшить некоторые моменты в GtkD более идиоматичным способом. У меня есть достаточное количество кода, где он может быть намного более кратким при соответствующем использовании CTFE. В качестве простого примера можно использовать диапазоны для поддержки итераций по различным артефактам с использованием foreach, а не классическим циклом. Тем не менее, я думаю, что некоторые из более сложных вариантов использования, таких как поддержка D-Bus (система межпроцессного взаимодействия, которая позволяет приложениям в операционной системе сообщаться друг с другом) и GObject, будут очень полезны.

    \n

    Для тех, кто не знаком с GObject — это объект базового уровня в GTK и является счётчиком ссылок. Возможность легко создавать GObjects в D, как можно и в Python, упростит взаимодействие с некоторыми из API; прямо сейчас это эквивалентно написанию его в сыром C, и это несколько трудоемко. Майк Вей, сопровождающий GtkD, начал делать некоторые работы над этим.

    \n

    Й.: — Вы упомянули на DConf, что D имеет быстрый цикл компиляции: как вы используете это, то есть какие IDE, компиляторы, toolchain (набор программ, необходимых для создания других программ) вы используете как для разработки, так и для выпуска релизов?

    \n

    Г.: — Я использую MS Visual Studio Code на Linux с отличным плагином code-d, написанным Jan «WebFreak» Jurzitza. Это дает мне все необходимые функции (автозаполнение кода, подсказки, рекомендации и т. Д.). Единственное, чего я не вижу, что есть в Java IDE, — это возможность рефакторинга. Для разработки я использую DMD, поскольку он имеет самое быстрое время компиляции. Сборку версий выполняю с использованием LDC, компилятора D с бэкэном LLVM, поскольку он генерирует меньшие и более быстрые двоичные файлы. Мне редко приходится запускать отладчик, но, когда я это делаю, я просто использую GDB из командной строки.

    \n

    Й.: — Какие проблемы у вас были с D? Какие его особенности вам не нравятся?

    \n

    Г.: — Никаких серьёзных проблем с моей точки зрения. Я в целом очень доволен этим языком и считаю, что он несёт правильный баланс между простотой использования и возможностями. Наибольшее неудобство мне доставляла стандартная библиотека Phobos, а не сам язык, и эти неудобства напрямую соотносятся с нехваткой времени.

    \n

    Никаких серьёзных проблем с Phobos, а скорее кучей раздражителей. Например, нельзя легко использовать immutable для отправки в std.concurrency, std.experimental.logger являющиеся все ещё экспериментальными, парсер json имеет проблемы, если в локализации установлена запятая для отделения десятичных знаков чисел и т.д. и т.п. Ни один из данных недочётов по себе не является особо важным, и большинство из скорее всего связаны из-за нехватки трудовых ресурсов. Я на самом деле несколько неохотно жалуюсь на них, потому что я, наверное, мог исправить их сам и отправить PR.

    \n

    Меня больше раздражает негативность на форумах в отношении GC. Я чувствую, что иногда люди так поворачиваются в сторону D, что хотят видеть его идеальным системным языком (т.е. без GC, без безопасности памяти и т.д.), упуская из виду, что он очень хороший язык для создания приложений на данный момент. Пока D сравнивают с Rust, в некотором смысле сравнение с Go для меня более интересно. Оба языка основаны на GC и оба начинали как системные языки, однако Go опирается на GC и «удваивается» (от переводчика: скорее всего имеется в виду рост популярности), добиваясь успеха. Один из продуктов Red Hat, который я поддерживаю, OpenShift, использует Kubernetes (проект Google) для управления кластером контейнеров Linux как единой системой, и он написан на Go.

    \n

    Я думаю, что как язык D намного превосходит Go, и мне хотелось, что мы бы заявляли об этом громче вместо постоянного отрицательного обсуждения системного программирования. На данный момент надо сказать ради справедливости, что Go имеет крупного корпоративного спонсора, в отличие от D, однако контраст в позиционировании по-прежнему интересен мне.

    \n

    Й.: — Каковы ваши будущие планы в отношении Tilix?

    \n

    Г.: — Две самые большие функции, которые я хотел бы добавить, это поддержка режима управления tmux и добавление возможности отображения боковой панели popout.

    \n

    Для тех, кто не знаком с tmux, это терминальный мультиплексор; это, по сути, терминальный разделитель, но внутри самого терминала. Он также поддерживает ряд других функций, но наиболее интересным является сохранение терминальных сеансов, находящихся вне терминала. Поскольку он работает в терминале, он немного ухудшает производительность, а с точки зрения графического интерфейса он не может использовать собственные виджеты, такие как полосы прокрутки. Чтобы смягчить это, он поддерживает так называемый режим управления, который позволяет ему интегрироваться с эмулятором тайлингово терминала для создания новых терминалов, т.е. подтерминалов внутри одного терминала, управляющихся за пределами tmux. Это значительно улучшает его производительность, позволяя пользователям использовать другие функции, поддерживаемые tmux. На данный момент поддерживается только iterm2 на OSX насколько мне известно.

    \n

    Боковая панель в Tilix является одним из наиболее противоречивых элементов интерфейса. Я решил не реализовывать интерфейс с вкладками, потому что я счел их бесполезными с точки зрения поиска открытия необходимой вкладки; там просто недостаточно места для вкладок, чтобы отличить их, и переименовывать их вручную — это «боль». Боковая панель — это моя попытка альтернативы, она отображает миниатюру каждого сеанса (известную также как ярлык) на боковой панели, которая может быть убрана по мере необходимости для переключения между сеансами.

    \n

    \"Боковая
    Боковая панель Tilix в действии

    \nБоковая панель Tilix в действии.

    \n

    У некоторых людей есть сильное предпочтение к тому, что постоянно доступно, но, к сожалению, все, что видно на боковой панели, непросто, так как генерация эскизов занимает значительное количество времени из-за структуры GTK. Существуют потенциальные способы заставить это работать быстрее, но требуется время на то, чтобы попробовать разные варианты, чтобы увидеть, что как отображается, а затем, что наиболее эффективно.

    \n

    В мечтах, я бы хотел переключиться на использование эмулятора терминала, написанного изначально в D, а не GTK VTE (Virtual Terminal Emulator), который написан на C, и который я использую сейчас. Для тех, кто не знаком с ним, VTE — это виджет эмуляции терминала, используемый терминалом Gnome и доступен в виде многоразового виджета. Многие эмуляторы терминала в Linux используют этот виджет (gnome-terminal, guake, terminator, tilix и т.д.), поскольку он обеспечивает полностью готовый к работке эмулятор, который прошел через огромное количество тестов.

    \n

    Недостатком этого является то, что любые пользовательские функции, которые вы хотите реализовать, которые включают фактический уровень эмуляции терминала, требуют модификации VTE и получения этих изменений вверх по потоку (*upstream). У меня есть несколько патчей, которые Tilix поддерживает (для триггеров и значков), но, честно говоря, я провёл плохую работу по внедрению вверх по потоку (*upstream). Частично это связано с тем, что VTE написано на C и сделать быстрый и качественный патч на C занимает достаточно много времени.

    \n

    Таким образом, наличие эмуляции терминала, написанного на D, сделало бы это намного проще, однако это огромные инвестиции времени, поскольку люди недооценивают объем работы. В эмуляции терминала много сложных случаев, плюс добавление всего материала, чтобы сделать его удобным для пользователей (поиск, клики по ссылкам и т.д.), это много, чтобы взять на себя. У меня просто нет времени, чтобы сделать это реальностью, если только я не выиграю в лотерею. Если кто-то захочет взять на себя это роль и создать виджет эмуляции терминала GTK, который имеет все необходимые функции и поддерживал бы его в течение длительного времени, я был бы рад работать с ним, чтобы интегрировать его с Tilix. Адам Рапп уже создал один, который работает достаточно хорошо по моим тестам; если кто-то хочет работать над преобразованием его в виджет GTK, добавит необходимые улучшения и согласится его поддерживать, то пусть не стесняется писать и надоедать сообщениями (*ping) мне. ?

    \n

    Й.: — Пожалуйста, расскажите из своего опыта, как вы впервые обнаружили D и использовали его для написания Tilix?

    \n

    Г.: — На протяжении всей моей карьеры я всегда имел хобби. Некоторые вещи, над которыми я работал, включали популярную надстройку для Delphi под названием Gexperts, замену проводника файлов Windows, Java IDE под названием Gel и популярное приложение для Android под названием OnTrack Diabetes, которое я продал несколько лет назад. Пару лет назад я искал что-то новое для работы в качестве моей программы в качества хобби и остановился на идее создания десктопного приложения для Linux. Я знал, что нужно использовать инструментарий GTK, так как Gnome — моя предпочтительная среда рабочего стола, и особенно с моим прошлым опытом Delphi в графических интерфейсах, которые я мог бы использовать. Я также знал, что меня не интересует разработка на C или C ++, поэтому я посмотрел, какие альтернативы были.

    \n

    Я начал с Python, так как у него отличная поддержка GTK, и я немного поработал над программированием Jython в WebLogic, так как я использовал Weblogic Scripting Tool (WLST). Однако большая часть моей предыдущей работы была небольшими сценариями, и я быстро понял, что в больших масштабах Python не для меня. Я вообще предпочитаю статически типизированные языки, и динамический набор текста на Python приводил меня в замешательство, особенно в качестве хобби, где я постоянно нуждался в использование справочных материалов, так как я работал на нем нечасто.

    \n

    Я также смотрел в сторону Rust и Go, но в то время ни у одного из них не было полнофункциональных GTK-привязок. Кроме того, в то время у Rust было много позитивной прессы, но у него был плохой материал для убеждения в правильности его подхода, и я был не так сильно убежден в том, что управление безопасностью памяти с помощью контролера заимствований было лучшим подходом, чем GC.

    \n

    Я знал о D, поскольку я посматривал на D много лет назад и полюбил этот язык, но экосистема была настолько слабой, что это было не сильно полезно для практической работы. Я взглянул на него снова и обнаружил, что он значительно улучшился и даже удивительно: были доступны полные привязки GtkD. Это также помогло тому, чтобы D и Java были достаточно похожи, и чтобы собирать на D было невероятно легко. Как только я узнал, как работают диапазоны, было легко начать кодить.

    \n

    Сначала я создал небольшое приложение под названием Visual Grep, которое переносит grep в графический интерфейс. Когда я консультировался, мне часто приходилось собирать большие базы кода, ищущие конкретные шаблоны, и графический интерфейс, который делал просмотр шаблонов, был абсолютной необходимостью. Это было отличное первое приложение, так как я узнал немало вещей о D. Производительность приложения была изначально плохой с большими наборами результатов, потому что GC постоянно был перегружен при загрузке результатов из-за постоянного распределения. Отключение GC во время этого tight-цикла улучшило производительность неизмеримо. Я также узнал об интеграции GTK с возможностями многопоточности D.

    \n

    Я был вдохновлен пользовательским интерфейсом от Gnome Builder IDE, и подумал, что он хорошо подойдёт для эмулятора терминала. Таким образом, Tilix родился. Ну, на самом деле сначала он назывался Terminix, но как только он начал получать популярность, я получил вежливую просьбу на переименование из-за компании Terminix, американской компании по борьбе с вредителями. Будучи канадцем, я не слишком хорошо их знал, поэтому я не много думал об этом имени. Извлеченный урок: потратьте время на выбор хорошего имени, если ваше приложение станет более популярным, чем вы ожидаете.

    \n

    Я наслаждаюсь временем, проведённым с D, и это отличный язык для создания настольных приложений в GTK. Во многих отношениях, я чувствую, что D является естественным преемником Vala, который был языком, созданным специально для создания приложений GTK, но постепенно умирающим, в основном из-за его узкой направленности. Если вы не создаёте приложения GTK, вы не используете Vala, что означает, что количество людей, использующих его и работающий над ним, по определению очень мало.

    \n

    Я также считаю, что D является естественным преемником Delphi, по крайней мере с GtkD, как мощным инструментом для создания настольных приложений. Благодаря быстрому времени компиляции и легкому изучению языка, он приносит множество лучших атрибутов Delphi в современную эпоху. Плюс я могу ввести {и} вместо Begin и End и увеличить свою эффективность на 75% или около того. ?

    \n

    Оригинал (англ.) http://dlang.org/blog/2017/08/11/on-tilix-and-d-an-interview-with-gerald-nunn/

    \n

    Что вынести из интервью

    \n

    Главный практический вывод прост: D может быть не только системным языком, но и удобным языком для прикладных desktop-инструментов. Tilix показывает, что связка D, GtkD и аккуратного следования GNOME HIG способна дать зрелое приложение, которым пользуются каждый день.

    \n

    В интервью хорошо видно, что выбор языка был не религиозным, а практическим. Автору не хотелось писать GUI на C или C++, Python оказался неудобен для крупного статически проверяемого приложения, а D дал знакомую после Java модель, нативную скорость и нормальные GTK-привязки. Это важный критерий выбора технологии: не «самый модный язык», а язык, на котором конкретный автор может быстро и спокойно делать продукт.

    \n

    Если хочется повторить такой путь, начинать стоит не с большого терминала, а с маленькой GTK-утилиты: окно, настройки, несколько действий, сборка через DUB, упаковка и обновления. На таком проекте сразу проявятся реальные вопросы: работа с GtkD, структура проекта, взаимодействие с C-библиотеками, обработка событий и паузы GC.

    \n\n

    Именно поэтому интервью полезно не только поклонникам D. Оно показывает нормальный инженерный путь: выбрать задачу, подобрать инструмент, сделать небольшую работающую версию, а затем постепенно доводить приложение до зрелого состояния.

    ", + "contentHtml": "

    Йоаким — интервьюер-резидент блога о D. Он также брал интервью у членов D-сообщества для This Week in D и ответственен за портирование LDC для Android.

    \n

    Геральд Нанн — разработчик Tilix (ранее называвшийся Terminix).

    \n

    Tilix— продвинутый тайлинговый эмулятор терминалов с открытым исходным кодом, который является самым «звёздным» проектом на основеD наGitHub, недавно даже обогнавший стандартный компилятор D, DMD. В этом году на DConf в Берлине он рассказывал о том, как использует D. Имеются слайды и видео. В своей повседневной работе, которая не имеет ничего общего с настольными графическими приложениями, он является старшим разработчиком промежуточных решений в Red Hat. Подробнее об истории Джеральда в [расширенном интервью— Ред.]

    \n

    Йоаким: — Что такое тайлинговый эмулятор терминала?

    \n

    Геральд:— Тайлинговый эмулятор терминала позволяет разделить терминал на несколько частей и распределить их в удобном порядке и месте, что наиболее удобно при работе над конкретной задачей. Люди, которые работают на нескольких терминалах одновременно, как правило, находят такие инструменты наиболее полезными, особенно с постоянно увеличивающимися размерами мониторов и разрешений.

    \n

    Несмотря на то, что Tilix очень хорош, основной причиной, по которой я создал его, было то, что мне нужен был эмулятор терминала, который следовал бы концепции Gnome HIG (Human Interface Guidelines — рекомендации по созданию интуитивных, легко изучаемых и логичных интерфейсов взаимодействия с пользователем) и использовал CSD (Client-Side Decorations — отрисовку на стороне клиента). Tilix следует Gnome HIG, опубликованным здесь, что означает соблюдение интервалов, макетов и других рекомендаций. После HIG важно, чтобы приложение соответствовало всему рабочему столу в целом.

    \n

    CSD ссылается на заголовок окна, где не диспетчер дисплея, а пользователь берёт на себя ответственность за него и может заполнять панель заголовка кнопками и другими элементами управления. Это часть концепции Gnome HIG, и большинство приложений Gnome (gedit, файлы, видео) по умолчанию используют этот подход. Единственное исключением является gnome-terminal, который вообще не использует CSD.

    \n

    Й.: — Можете привести несколько примеров того, как вы используете концепцию Gnome HIG?

    \n

    Г.: — Gnome HIG определяет конкретный язык дизайна в отношении того, как приложения, работающие в Gnome, должны выглядеть и показывать себя. Некоторые примеры Tilix, следующие концепции HIG, включают использование CSD и меню приложений по рекомендациям, таким как интервалы, макеты и т.д. Кроме того, разработчики Gnome собрали множество макетов, как они думают, как должны выглядеть различные приложения. Tilix использует макеты, разработанные дизайнерами Gnome для терминала, где это возможно. Например, в Tilix диалог предпочтений и профилей использовался отдельно, однако один из дизайнеров Gnome предложил этот макет для gnome-терминала. Я пошел вперед и реализовал его в Tilix, гораздо лучше, чем раньше.

    \n

    В результате использования CSD и концепции Gnome HIG, надеюсь, что использование Tilix в Gnome более органичено для пользователей.

    \n

    Интересная вещь — это напряженность между людьми, которые используют Tilix на Gnome и тех, кто использует его в других дистрибутивах. Хотя я не имею никаких сомнений в том, что разработка под Gnome является моей основной целью, я стараюсь сделать Tilix лучше и в других средах рабочего стола, разрешив пользователю отключить CSD в пользу обычного заголовка, если они того пожелают.

    \n

    Й.: — Вы попали в D из среды Java. Вы все еще пишете код в стиле Java на D? Это было легко, т.е. сколько вам пришлось привыкать, чтобы писать на D?

    \n

    Г.: — Да, чаще всего работаю с Java. Я нашёл это довольно интересным на DConf, когда я спросил, как много людей пришли не из среды C/C++ , только один человек поднял руку.

    \n

    Если вы посмотрите на мой код в Tilix, то он очень похож на Java-код. Некоторое из этого связано с моей обычное средой, в которой я работаю, а некоторое из-за того, что GtkD является оболочкой классов.

    \n

    Я считаю, что переключение между D и Java является довольно плавным по большей части; между ними гораздо меньше когнитивного трения, чем, скажем, между переключением между Java и Python.

    \n

    Самая важная идиома D, которую мне пришлось изучить, — это диапазоны, поскольку они являются основополагающей особенностью D. Однако это не было сильно сложным. Выполнение функций времени компиляции (CTFE) по-прежнему остаются для меня неестественными. Когда я использую их, мне приходится каждый раз искать про них информацию, и мои текущие попытки использования CTFE с точки зрения кода довольны глупы. Я хотел бы больше использовать CTFE в Tilix, поскольку я получаю всё больший опыт использования D.

    \n

    Наконец, мой недостаток опыта работы с C выявляет другую проблему, с которой я немного борюсь — это взаимодействие с кодом на C. Хотя по большей части это довольно просто, но, когда мне приходится расшифровывать что-то сложное, оно становится не таким простым. Поддержка FlatPak (система изолированных контейнеров для графических приложений) в настоящее время есть, так как мне не приходилось так сильно напрягаться по некоторым вопросам, с которыми я сталкивался на C.

    \n

    Сказав это, у меня есть некоторый опыт разработки собственного кода, поскольку много лет назад я потратил много времени на разработку кода на Delphi и Object Pascal. Именно на это и приходится большая часть моего опыта работы с графическим интерфейсом.

    \n

    Й.: — И из вашего выступления на DConf вы явное не беспокоились о сборщике мусора (GC). Вам приходилось думать о нём, когда вы разрабатывали Tilix? Имелись ли проблемы задержек с GUI, вызванные GC?

    \n

    Г.: — Придя из Java, GC для меня довольно естественен, и я определенно не считаю его плохим для D. Я думаю, что GC в D получает много плохой прессы на основе опыта Java, но важно помнить, что GC в D сильно отличается от того, что в Java. Самое большое различие для меня в том, что в D есть больше возможностей для его управления, так как он хорошо понимает, когда он может начать цикл GC. Я был очень рад видеть, как больше людей поднимают различные темы на reddit и форумах, жалуются на использование GC.

    \n

    У меня не было никаких проблем с GC с точки зрения пауз, и ни один пользователь Tilix не сообщал об этом. У меня было несколько проблем, связанных с GC, в основном связанных с утечкой памяти из-за хранения ссылок, но все они были ошибками программного кода, а не проблемой с реализацией GC в D. У меня есть другое приложение на GTK D, Visual Grep, где я столкнулся с ужасающей эффективностью при обработке большого количества совпадений в tight-цикле (цикл, который содержит несколько инструкций и повторяется много раз.). Тем не менее, просто отключив GC для этого раздела кода, ускорилось все.

    \n

    Й.: — Репозиторий github для Tilix замечательно чист, нет открытых запросов «pull request» (PR) и низкий процент проблем, которые все еще открыты. Сколько времени вы еженедельно тратите на Tilix? Является ли Tilix только хобби или он стал чем-то большим?

    \n

    Г.: — Tilix — это просто хобби. Я, вероятно, провожу от 5 до 10 часов в неделю. На данный момент — это зрелое приложение, следовательно, относительно небольшое количество проблем. Я также уделяю приоритетное внимание исправлению ошибок при добавлении новых функций, что помогает сохранить список управляемым.

    \n

    Что касается запросов на «pull request» (PR), я твердо убежден в том, что я должен быть отзывчивым, поэтому я, как правило, отвечаю на PR в течение дня или двух. В качестве участника разработок я знаю, что ничего не убивает интерес, как видеть, что ваш PR томится в течение нескольких недель, месяцев или даже лет. Если вы хотите, чтобы люди вносили свой вклад, что я определенно делаю, то я чувствую, что вы обязаны участникам разработки своевременно реагировать на их PR.

    \n

    На данный момент Tiltx — это относительно небольшой проект, поэтому мне легко принять этот подход. Я понимаю, почему более крупные проекты могут иметь больше проблем в этой области.

    \n

    Й.: — D имеет много особенностей, насколько хорошо вы их знаете? Вы упомянули, что хотите использовать некоторые из возможностей времени компиляции. Как вы думаете за счёт каких особенностей D и что Tilix выиграет в будущем и как?

    \n

    Г.: — Я не думаю, что знаю это, если честно, помимо основного набора особенностей, которые я использую в Tilix. Я всегда удивляюсь ребятам на форуме, которые могут утверждать достоинства/недостатки низкоуровневых деталей языка. Это определенно не я. Я бы хотел поправиться, но на самом деле я использую D только как хобби, поэтому я не могу тратить столько же времени, сколько и на Java. Кроме того, моя работа в Red Hat имеет больше компонентов инфраструктуры, чем мои предыдущие рабочие места, поэтому большая часть моего времени расходуется дома на обучение в этом направлении.

    \n

    Что касается возможностей, от которых Tilix выиграет, я думаю, что использование CTFE и диапазонов было бы очень полезно для того, чтобы улучшить некоторые моменты в GtkD более идиоматичным способом. У меня есть достаточное количество кода, где он может быть намного более кратким при соответствующем использовании CTFE. В качестве простого примера можно использовать диапазоны для поддержки итераций по различным артефактам с использованием foreach, а не классическим циклом. Тем не менее, я думаю, что некоторые из более сложных вариантов использования, таких как поддержка D-Bus (система межпроцессного взаимодействия, которая позволяет приложениям в операционной системе сообщаться друг с другом) и GObject, будут очень полезны.

    \n

    Для тех, кто не знаком с GObject — это объект базового уровня в GTK и является счётчиком ссылок. Возможность легко создавать GObjects в D, как можно и в Python, упростит взаимодействие с некоторыми из API; прямо сейчас это эквивалентно написанию его в сыром C, и это несколько трудоемко. Майк Вей, сопровождающий GtkD, начал делать некоторые работы над этим.

    \n

    Й.: — Вы упомянули на DConf, что D имеет быстрый цикл компиляции: как вы используете это, то есть какие IDE, компиляторы, toolchain (набор программ, необходимых для создания других программ) вы используете как для разработки, так и для выпуска релизов?

    \n

    Г.: — Я использую MS Visual Studio Code на Linux с отличным плагином code-d, написанным Jan «WebFreak» Jurzitza. Это дает мне все необходимые функции (автозаполнение кода, подсказки, рекомендации и т. Д.). Единственное, чего я не вижу, что есть в Java IDE, — это возможность рефакторинга. Для разработки я использую DMD, поскольку он имеет самое быстрое время компиляции. Сборку версий выполняю с использованием LDC, компилятора D с бэкэном LLVM, поскольку он генерирует меньшие и более быстрые двоичные файлы. Мне редко приходится запускать отладчик, но, когда я это делаю, я просто использую GDB из командной строки.

    \n

    Й.: — Какие проблемы у вас были с D? Какие его особенности вам не нравятся?

    \n

    Г.: — Никаких серьёзных проблем с моей точки зрения. Я в целом очень доволен этим языком и считаю, что он несёт правильный баланс между простотой использования и возможностями. Наибольшее неудобство мне доставляла стандартная библиотека Phobos, а не сам язык, и эти неудобства напрямую соотносятся с нехваткой времени.

    \n

    Никаких серьёзных проблем с Phobos, а скорее кучей раздражителей. Например, нельзя легко использовать immutable для отправки в std.concurrency, std.experimental.logger являющиеся все ещё экспериментальными, парсер json имеет проблемы, если в локализации установлена запятая для отделения десятичных знаков чисел и т.д. и т.п. Ни один из данных недочётов по себе не является особо важным, и большинство из скорее всего связаны из-за нехватки трудовых ресурсов. Я на самом деле несколько неохотно жалуюсь на них, потому что я, наверное, мог исправить их сам и отправить PR.

    \n

    Меня больше раздражает негативность на форумах в отношении GC. Я чувствую, что иногда люди так поворачиваются в сторону D, что хотят видеть его идеальным системным языком (т.е. без GC, без безопасности памяти и т.д.), упуская из виду, что он очень хороший язык для создания приложений на данный момент. Пока D сравнивают с Rust, в некотором смысле сравнение с Go для меня более интересно. Оба языка основаны на GC и оба начинали как системные языки, однако Go опирается на GC и «удваивается» (от переводчика: скорее всего имеется в виду рост популярности), добиваясь успеха. Один из продуктов Red Hat, который я поддерживаю, OpenShift, использует Kubernetes (проект Google) для управления кластером контейнеров Linux как единой системой, и он написан на Go.

    \n

    Я думаю, что как язык D намного превосходит Go, и мне хотелось, что мы бы заявляли об этом громче вместо постоянного отрицательного обсуждения системного программирования. На данный момент надо сказать ради справедливости, что Go имеет крупного корпоративного спонсора, в отличие от D, однако контраст в позиционировании по-прежнему интересен мне.

    \n

    Й.: — Каковы ваши будущие планы в отношении Tilix?

    \n

    Г.: — Две самые большие функции, которые я хотел бы добавить, это поддержка режима управления tmuxи добавление возможности отображения боковой панели popout.

    \n

    Для тех, кто не знаком с tmux, это терминальный мультиплексор; это, по сути, терминальный разделитель, но внутри самого терминала. Он также поддерживает ряд других функций, но наиболее интересным является сохранение терминальных сеансов, находящихся вне терминала. Поскольку он работает в терминале, он немного ухудшает производительность, а с точки зрения графического интерфейса он не может использовать собственные виджеты, такие как полосы прокрутки. Чтобы смягчить это, он поддерживает так называемый режим управления, который позволяет ему интегрироваться с эмулятором тайлингово терминала для создания новых терминалов, т.е. подтерминалов внутри одного терминала, управляющихся за пределами tmux. Это значительно улучшает его производительность, позволяя пользователям использовать другие функции, поддерживаемые tmux. На данный момент поддерживается только iterm2 на OSX насколько мне известно.

    \n

    Боковая панель в Tilix является одним из наиболее противоречивых элементов интерфейса. Я решил не реализовывать интерфейс с вкладками, потому что я счел их бесполезными с точки зрения поиска открытия необходимой вкладки; там просто недостаточно места для вкладок, чтобы отличить их, и переименовывать их вручную — это «боль». Боковая панель — это моя попытка альтернативы, она отображает миниатюру каждого сеанса (известную также как ярлык) на боковой панели, которая может быть убрана по мере необходимости для переключения между сеансами.

    \n

    \"Боковая Боковая панель Tilix в действии

    \n

    Боковая панель Tilix в действии.

    \n

    У некоторых людей есть сильное предпочтение к тому, что постоянно доступно, но, к сожалению, все, что видно на боковой панели, непросто, так как генерация эскизов занимает значительное количество времени из-за структуры GTK. Существуют потенциальные способы заставить это работать быстрее, но требуется время на то, чтобы попробовать разные варианты, чтобы увидеть, что как отображается, а затем, что наиболее эффективно.

    \n

    В мечтах, я бы хотел переключиться на использование эмулятора терминала, написанного изначально в D, а не GTK VTE (Virtual Terminal Emulator), который написан на C, и который я использую сейчас. Для тех, кто не знаком с ним, VTE — это виджет эмуляции терминала, используемый терминалом Gnome и доступен в виде многоразового виджета. Многие эмуляторы терминала в Linux используют этот виджет (gnome-terminal, guake, terminator, tilix и т.д.), поскольку он обеспечивает полностью готовый к работке эмулятор, который прошел через огромное количество тестов.

    \n

    Недостатком этого является то, что любые пользовательские функции, которые вы хотите реализовать, которые включают фактический уровень эмуляции терминала, требуют модификации VTE и получения этих изменений вверх по потоку (upstream). У меня есть несколько патчей, которые Tilix поддерживает (для триггеров и значков), но, честно говоря, я провёл плохую работу по внедрению вверх по потоку (upstream). Частично это связано с тем, что VTE написано на C и сделать быстрый и качественный патч на C занимает достаточно много времени.

    \n

    Таким образом, наличие эмуляции терминала, написанного на D, сделало бы это намного проще, однако это огромные инвестиции времени, поскольку люди недооценивают объем работы. В эмуляции терминала много сложных случаев, плюс добавление всего материала, чтобы сделать его удобным для пользователей (поиск, клики по ссылкам и т.д.), это много, чтобы взять на себя. У меня просто нет времени, чтобы сделать это реальностью, если только я не выиграю в лотерею. Если кто-то захочет взять на себя это роль и создать виджет эмуляции терминала GTK, который имеет все необходимые функции и поддерживал бы его в течение длительного времени, я был бы рад работать с ним, чтобы интегрировать его с Tilix. Адам Рапп уже создал один, который работает достаточно хорошо по моим тестам; если кто-то хочет работать над преобразованием его в виджет GTK, добавит необходимые улучшения и согласится его поддерживать, то пусть не стесняется писать и надоедать сообщениями (*ping) мне. ?

    \n

    Й.: — Пожалуйста, расскажите из своего опыта, как вы впервые обнаружили D и использовали его для написания Tilix?

    \n

    Г.: — На протяжении всей моей карьеры я всегда имел хобби. Некоторые вещи, над которыми я работал, включали популярную надстройку для Delphi под названием Gexperts, замену проводника файлов Windows, Java IDE под названием Gel и популярное приложение для Android под названием OnTrack Diabetes, которое я продал несколько лет назад. Пару лет назад я искал что-то новое для работы в качестве моей программы в качества хобби и остановился на идее создания десктопного приложения для Linux. Я знал, что нужно использовать инструментарий GTK, так как Gnome — моя предпочтительная среда рабочего стола, и особенно с моим прошлым опытом Delphi в графических интерфейсах, которые я мог бы использовать. Я также знал, что меня не интересует разработка на C или C ++, поэтому я посмотрел, какие альтернативы были.

    \n

    Я начал с Python, так как у него отличная поддержка GTK, и я немного поработал над программированием Jython в WebLogic, так как я использовал Weblogic Scripting Tool (WLST). Однако большая часть моей предыдущей работы была небольшими сценариями, и я быстро понял, что в больших масштабах Python не для меня. Я вообще предпочитаю статически типизированные языки, и динамический набор текста на Python приводил меня в замешательство, особенно в качестве хобби, где я постоянно нуждался в использование справочных материалов, так как я работал на нем нечасто.

    \n

    Я также смотрел в сторону Rust и Go, но в то время ни у одного из них не было полнофункциональных GTK-привязок. Кроме того, в то время у Rust было много позитивной прессы, но у него был плохой материал для убеждения в правильности его подхода, и я был не так сильно убежден в том, что управление безопасностью памяти с помощью контролера заимствований было лучшим подходом, чем GC.

    \n

    Я знал о D, поскольку я посматривал на D много лет назад и полюбил этот язык, но экосистема была настолько слабой, что это было не сильно полезно для практической работы. Я взглянул на него снова и обнаружил, что он значительно улучшился и даже удивительно: были доступны полные привязки GtkD. Это также помогло тому, чтобы D и Java были достаточно похожи, и чтобы собирать на D было невероятно легко. Как только я узнал, как работают диапазоны, было легко начать кодить.

    \n

    Сначала я создал небольшое приложение под названием Visual Grep, которое переносит grep в графический интерфейс. Когда я консультировался, мне часто приходилось собирать большие базы кода, ищущие конкретные шаблоны, и графический интерфейс, который делал просмотр шаблонов, был абсолютной необходимостью. Это было отличное первое приложение, так как я узнал немало вещей о D. Производительность приложения была изначально плохой с большими наборами результатов, потому что GC постоянно был перегружен при загрузке результатов из-за постоянного распределения. Отключение GC во время этого tight-цикла улучшило производительность неизмеримо. Я также узнал об интеграции GTK с возможностями многопоточности D.

    \n

    Я был вдохновлен пользовательским интерфейсом от Gnome Builder IDE, и подумал, что он хорошо подойдёт для эмулятора терминала. Таким образом, Tilix родился. Ну, на самом деле сначала он назывался Terminix, но как только он начал получать популярность, я получил вежливую просьбу на переименование из-за компании Terminix, американской компании по борьбе с вредителями. Будучи канадцем, я не слишком хорошо их знал, поэтому я не много думал об этом имени. Извлеченный урок: потратьте время на выбор хорошего имени, если ваше приложение станет более популярным, чем вы ожидаете.

    \n

    Я наслаждаюсь временем, проведённым с D, и это отличный язык для создания настольных приложений в GTK. Во многих отношениях, я чувствую, что D является естественным преемником Vala, который был языком, созданным специально для создания приложений GTK, но постепенно умирающим, в основном из-за его узкой направленности. Если вы не создаёте приложения GTK, вы не используете Vala, что означает, что количество людей, использующих его и работающий над ним, по определению очень мало.

    \n

    Я также считаю, что D является естественным преемником Delphi, по крайней мере с GtkD, как мощным инструментом для создания настольных приложений. Благодаря быстрому времени компиляции и легкому изучению языка, он приносит множество лучших атрибутов Delphi в современную эпоху. Плюс я могу ввести {и} вместо Begin и End и увеличить свою эффективность на 75% или около того. ?

    \n

    Оригинал (англ.) http://dlang.org/blog/2017/08/11/on-tilix-and-d-an-interview-with-gerald-nunn/

    \n

    Что вынести из интервью

    \n

    Главный практический вывод прост: D может быть не только системным языком, но и удобным языком для прикладных desktop-инструментов. Tilix показывает, что связка D, GtkD и аккуратного следования GNOME HIG способна дать зрелое приложение, которым пользуются каждый день.

    \n

    В интервью хорошо видно, что выбор языка был не религиозным, а практическим. Автору не хотелось писать GUI на C или C++, Python оказался неудобен для крупного статически проверяемого приложения, а D дал знакомую после Java модель, нативную скорость и нормальные GTK-привязки. Это важный критерий выбора технологии: не «самый модный язык», а язык, на котором конкретный автор может быстро и спокойно делать продукт.

    \n

    Если хочется повторить такой путь, начинать стоит не с большого терминала, а с маленькой GTK-утилиты: окно, настройки, несколько действий, сборка через DUB, упаковка и обновления. На таком проекте сразу проявятся реальные вопросы: работа с GtkD, структура проекта, взаимодействие с C-библиотеками, обработка событий и паузы GC.

    \n\n

    Именно поэтому интервью полезно не только поклонникам D. Оно показывает нормальный инженерный путь: выбрать задачу, подобрать инструмент, сделать небольшую работающую версию, а затем постепенно доводить приложение до зрелого состояния.

    ", "readingMinutes": 19 }, { @@ -51,7 +51,7 @@ ], "cover": "/assets/illustrations/bitrix-translit-api.svg", "excerpt": "Часто в Bitrix необходимо сгенерировать код элемента из имени. Привожу пример реализации именно такой функции с помощью функции Bitrix API для транслита CUtil::translit :", - "contentHtml": "

    Часто в Bitrix необходимо сгенерировать код элемента из имени.
    \nПривожу пример реализации именно такой функции с помощью функции Bitrix API для транслита CUtil::translit:

    \n\n
    function strToElementCode($str, $maxLength = 100) {\n\t$params = array(\n\t\t\t"max_len" => $maxLength,\n\t\t\t"change_case" => "L",\n\t\t\t"replace_space" => "_",\n\t\t\t"replace_other" => "_",\n\t\t\t"delete_repeat_replace" => "true",\n\t\t\t"use_google" => "false",\n\t\t);\n\treturn CUtil::translit($str, "ru", $params);\n}
    \n

    Как использовать функцию безопасно

    \n

    Транслитерация имени удобна, но код элемента должен оставаться уникальным. Поэтому после генерации проверяйте существующие символьные коды в инфоблоке и при совпадении добавляйте числовой суффикс. Иначе два товара с похожим названием могут получить один URL.

    \n
    telefon-samsung\ntelefon-samsung-2\ntelefon-samsung-3
    \n

    Также стоит сразу нормализовать результат: привести к нижнему регистру, заменить пробелы на дефис, убрать повторяющиеся дефисы и обрезать дефис в начале или конце строки. Это мелочь, но именно такие мелочи потом портят адреса страниц.

    \n
    $code = CUtil::translit($name, 'ru', [\n    'replace_space' => '-',\n    'replace_other' => '-',\n    'change_case' => 'L',\n]);\n\n$code = trim(preg_replace('/-+/', '-', $code), '-');
    \n

    Проверку уникальности лучше делать в том же инфоблоке, где будет создан элемент. Если сайт многоязычный или есть разделы с одинаковыми товарами, заранее решите правило: уникальность на весь инфоблок или только внутри раздела. Для SEO обычно проще и надёжнее уникальность на весь инфоблок.

    \n

    В итоге функция должна не просто транслитерировать строку, а возвращать готовый символьный код: чистый, нижнего регистра, без мусора и без совпадений с уже существующими элементами.

    ", + "contentHtml": "

    Часто в Bitrix необходимо сгенерировать код элемента из имени. Привожу пример реализации именно такой функции с помощью функции Bitrix API для транслита CUtil::translit:

    \n
    function strToElementCode($str, $maxLength = 100) {\n\t$params = array(\n\t\t\t"max_len" => $maxLength,\n\t\t\t"change_case" => "L",\n\t\t\t"replace_space" => "_",\n\t\t\t"replace_other" => "_",\n\t\t\t"delete_repeat_replace" => "true",\n\t\t\t"use_google" => "false",\n\t\t);\n\treturn CUtil::translit($str, "ru", $params);\n}
    \n

    Как использовать функцию безопасно

    \n

    Транслитерация имени удобна, но код элемента должен оставаться уникальным. Поэтому после генерации проверяйте существующие символьные коды в инфоблоке и при совпадении добавляйте числовой суффикс. Иначе два товара с похожим названием могут получить один URL.

    \n
    telefon-samsung\ntelefon-samsung-2\ntelefon-samsung-3
    \n

    Также стоит сразу нормализовать результат: привести к нижнему регистру, заменить пробелы на дефис, убрать повторяющиеся дефисы и обрезать дефис в начале или конце строки. Это мелочь, но именно такие мелочи потом портят адреса страниц.

    \n
    $code = CUtil::translit($name, 'ru', [\n    'replace_space' => '-',\n    'replace_other' => '-',\n    'change_case' => 'L',\n]);\n\n$code = trim(preg_replace('/-+/', '-', $code), '-');
    \n

    Проверку уникальности лучше делать в том же инфоблоке, где будет создан элемент. Если сайт многоязычный или есть разделы с одинаковыми товарами, заранее решите правило: уникальность на весь инфоблок или только внутри раздела. Для SEO обычно проще и надёжнее уникальность на весь инфоблок.

    \n

    В итоге функция должна не просто транслитерировать строку, а возвращать готовый символьный код: чистый, нижнего регистра, без мусора и без совпадений с уже существующими элементами.

    ", "readingMinutes": 1 }, { @@ -65,7 +65,7 @@ ], "cover": "/assets/illustrations/bitrix-offer-api.svg", "excerpt": "Хороший пример создания (добавления) торгового предложения в Bitrix через API :", - "contentHtml": "

    Хороший пример создания (добавления) торгового предложения в Bitrix через API:

    \n\n
    use \\Bitrix\\Main\\Loader;\n \nif (!Loader::includeModule('iblock') || !Loader::includeModule('catalog'))\n{\n\tdie('Error loading module iblock or catalog');\n}\n \n$IBlockOffersCatalogId = 3; // ID инфоблока предложений (должен быть торговым каталогом)\n$productName = "Товар"; // наименование товара\n$offerName = "Торговое предложение"; // наименование торгового предложения\n$offerPrice = 100.50; // Цена торгового предложения\n \n \n$arCatalog = CCatalog::GetByID($IBlockOffersCatalogId);\n \n$IBlockCatalogId = $arCatalog['PRODUCT_IBLOCK_ID']; // ID инфоблока товаров\n$SKUPropertyId = $arCatalog['SKU_PROPERTY_ID']; // ID свойства в инфоблоке предложений типа "Привязка к товарам (SKU)"\n \n$obElement = new CIBlockElement();\n$arFields = array(\n   'NAME' => $productName,\n   'IBLOCK_ID' => $IBlockCatalogId,\n   'ACTIVE' => 'Y'\n);\n$productId = $obElement->Add($arFields); // добавили товар, получили ID\n \nif ($productId)\n{\n\t$obElement = new CIBlockElement();\n\t// свойства торгвоого предложения\n\t$arOfferProps = array(\n\t\t$SKUPropertyId => $productId,\n\t);\n\t$arOfferFields = array(\n\t\t'NAME' => $offerName,\n\t\t'IBLOCK_ID' => $IBlockOffersCatalogId,\n\t\t'ACTIVE' => 'Y',\n\t\t'PROPERTY_VALUES' => $arOfferProps\n\t);\n \n\t$offerId = $obElement->Add($arOfferFields); // ID торгового предложения\n \n\tif ($offerId)\n\t{\n\t\t// добавляем как товар и указываем цену\n\t\t$catalogProductAddResult =\tCCatalogProduct::Add(array(\n\t\t\t\t"ID" => $offersId,\n\t\t\t\t"VAT_INCLUDED" => "Y", //НДС входит в стоимость\n\t\t\t));\n\t\tif ($catalogProductAddResult && !CPrice::SetBasePrice($offerId, $offerPrice, "RUB"))\n\t\t\tthrow new Exception("Ошибка установки цены торгового предложения \\"{$offerId}\\"");\n\t\telse\n\t\t\tthrow new Exception("Ошибка добавления параметров торгового предложения \\"{$offerId}\\" в каталог товаров");\n\t}\n\telse\n\t{\n\t\tthrow new Exception("Ошибка добавления торгового предложения: " . $obElement->LAST_ERROR);\n\t}\n}\nelse\n{\n\tthrow new Exception("Ошибка добавления товара: " . $obElement->LAST_ERROR);\n}
    \n

    Что проверить после создания предложения

    \n

    После добавления торгового предложения проверьте три связи: привязку к товару, цены и остатки. В Bitrix предложение может успешно создаться, но не появиться в публичной части, если не заполнены обязательные свойства SKU, не сохранён товарный каталог или не обновлены кеши.

    \n\n

    Минимальная последовательность такая: создать элемент торгового предложения в инфоблоке SKU, записать связь с товаром, сохранить параметры товара, установить цену, установить количество. Если хотя бы один шаг пропущен, предложение может быть видно в админке, но не попадёт в публичный каталог.

    \n
    CModule::IncludeModule('iblock');\nCModule::IncludeModule('catalog');\n\n$offerId = $el->Add($fields);\nCCatalogProduct::Add([\n    'ID' => $offerId,\n    'QUANTITY' => 10,\n]);\nCPrice::SetBasePrice($offerId, 990, 'RUB');
    \n

    На боевом проекте обязательно логируйте результат Add и текст ошибки LAST_ERROR. Bitrix часто молча возвращает false, а настоящая причина находится именно там: обязательное поле, неправильный инфоблок, неверный код свойства или недостаточные права.

    ", + "contentHtml": "

    Хороший пример создания (добавления) торгового предложения в Bitrix через API:

    \n
    use \\Bitrix\\Main\\Loader;\n\nif (!Loader::includeModule('iblock') || !Loader::includeModule('catalog'))\n{\n\tdie('Error loading module iblock or catalog');\n}\n\n$IBlockOffersCatalogId = 3; // ID инфоблока предложений (должен быть торговым каталогом)\n$productName = "Товар"; // наименование товара\n$offerName = "Торговое предложение"; // наименование торгового предложения\n$offerPrice = 100.50; // Цена торгового предложения\n\n$arCatalog = CCatalog::GetByID($IBlockOffersCatalogId);\n\n$IBlockCatalogId = $arCatalog['PRODUCT_IBLOCK_ID']; // ID инфоблока товаров\n$SKUPropertyId = $arCatalog['SKU_PROPERTY_ID']; // ID свойства в инфоблоке предложений типа "Привязка к товарам (SKU)"\n\n$obElement = new CIBlockElement();\n$arFields = array(\n   'NAME' => $productName,\n   'IBLOCK_ID' => $IBlockCatalogId,\n   'ACTIVE' => 'Y'\n);\n$productId = $obElement->Add($arFields); // добавили товар, получили ID\n\nif ($productId)\n{\n\t$obElement = new CIBlockElement();\n\t// свойства торгвоого предложения\n\t$arOfferProps = array(\n\t\t$SKUPropertyId => $productId,\n\t);\n\t$arOfferFields = array(\n\t\t'NAME' => $offerName,\n\t\t'IBLOCK_ID' => $IBlockOffersCatalogId,\n\t\t'ACTIVE' => 'Y',\n\t\t'PROPERTY_VALUES' => $arOfferProps\n\t);\n\n\t$offerId = $obElement->Add($arOfferFields); // ID торгового предложения\n\n\tif ($offerId)\n\t{\n\t\t// добавляем как товар и указываем цену\n\t\t$catalogProductAddResult =\tCCatalogProduct::Add(array(\n\t\t\t\t"ID" => $offersId,\n\t\t\t\t"VAT_INCLUDED" => "Y", //НДС входит в стоимость\n\t\t\t));\n\t\tif ($catalogProductAddResult && !CPrice::SetBasePrice($offerId, $offerPrice, "RUB"))\n\t\t\tthrow new Exception("Ошибка установки цены торгового предложения \\"{$offerId}\\"");\n\t\telse\n\t\t\tthrow new Exception("Ошибка добавления параметров торгового предложения \\"{$offerId}\\" в каталог товаров");\n\t}\n\telse\n\t{\n\t\tthrow new Exception("Ошибка добавления торгового предложения: " . $obElement->LAST_ERROR);\n\t}\n}\nelse\n{\n\tthrow new Exception("Ошибка добавления товара: " . $obElement->LAST_ERROR);\n}
    \n

    Что проверить после создания предложения

    \n

    После добавления торгового предложения проверьте три связи: привязку к товару, цены и остатки. В Bitrix предложение может успешно создаться, но не появиться в публичной части, если не заполнены обязательные свойства SKU, не сохранён товарный каталог или не обновлены кеши.

    \n\n

    Минимальная последовательность такая: создать элемент торгового предложения в инфоблоке SKU, записать связь с товаром, сохранить параметры товара, установить цену, установить количество. Если хотя бы один шаг пропущен, предложение может быть видно в админке, но не попадёт в публичный каталог.

    \n
    CModule::IncludeModule('iblock');\nCModule::IncludeModule('catalog');\n\n$offerId = $el->Add($fields);\nCCatalogProduct::Add([\n    'ID' => $offerId,\n    'QUANTITY' => 10,\n]);\nCPrice::SetBasePrice($offerId, 990, 'RUB');
    \n

    На боевом проекте обязательно логируйте результат Add и текст ошибки LAST_ERROR. Bitrix часто молча возвращает false, а настоящая причина находится именно там: обязательное поле, неправильный инфоблок, неверный код свойства или недостаточные права.

    ", "readingMinutes": 2 }, { @@ -121,7 +121,7 @@ ], "cover": "/assets/illustrations/preg-match-all-d.svg", "excerpt": "В PHP есть очень удобная функция для глобального поиска шаблона регулярного выражения в строке preg_match_all . Давайте напишем аналогичный класс статических методов для реализации этой функции с разными флагами на языке программирования D. Описание функции: i", - "contentHtml": "

    В PHP есть очень удобная функция для глобального поиска шаблона регулярного выражения в строке preg_match_all. Давайте напишем аналогичный класс статических методов для реализации этой функции с разными флагами на языке программирования D.

    \n

    Описание функции:

    \n\n
    int preg_match_all ( string $pattern , string $subject [, array &amp;$matches [, int $flags = PREG_PATTERN_ORDER [, int $offset = 0 ]]] )
    \n\n

    Функция ищет в строке subject все совпадения с шаблоном pattern и помещает результат в массив matches в порядке, определяемом комбинацией флагов flags.
    \nПосле нахождения первого соответствия последующие поиски будут осуществляться не с начала строки, а от конца последнего найденного вхождения.

    \n

    Данная функцию очень удобна и чаще всего она применяется с третьим параметром, чтобы обработать полученный массив совпадений шаблона. В D подобной регулярной функции нет. Мне вообще не сильно нравятся, как реализованы регулярные выражения в D. Ну да ладно.

    \n

    Полную документацию по функции можете посмотреть здесь http://php.net/manual/ru/function.preg-match-all.php.

    \n

    Возможные флаги функции preg_match_all:

    \n\n

    Не будем заморачиваться с реализацией через передачу константы, как в PHP, или через шаблоны в D. Создадим для каждого флага PHP данной функции аналогичные статические методы. Параметр offset оставим за бортом. Класс давайте назовём PReg. Вырисовывается следующая структура:

    \n\n
    class PReg\n{\n\tpublic static:\n \n\t… matchAllPatternOrder (...) {...}\n\t… matchAllSetOrder (...){...}\n\t… matchAllOffsetCapture (…) {...}\n}
    \n\n

    Как параметры будем передавать строку для поиска (тип string), регулярное выражение (тип ???) и переменную для вывода количества совпадений шаблона регулярного выражения (тип out int). Тип регулярного выражения можно посмотреть, чтобы не капаться в модуле, применив такую хитрость:

    \n
    writeln(typeof(regex(`\\d+`)).stringof);
    \n

    Также как аргумент вы без проблем сможете передавать compile-time регулярное выражение (ctRegex). Для данного типа создадим алиас типа, чтобы поудобнее было.
    \nФункция matchAllPatternOrder и matchAllSetOrder будет возвращать двумерный массив строк, для них тоже создадим алиас типов (мне так кажется, что путаницы меньше). MatchAllOffsetCapture пока оставим на закуску.

    \n

    В итоге у нас получается:

    \n\n
    class PReg\n{\n\tprivate static alias typeRegex = Regex!char;\n \n\tpublic static:\t\n\talias typePatternOrder = string[];\n\talias typeSetOrder = string[];\n \n\ttypePatternOrder[] matchAllPatternOrder (string subject, typeRegex obRegex, out int count) {..}\n\ttypeSetOrder[] matchAllSetOrder (string subject, typeRegex obRegex, out int count){...}\n\t… matchAllOffsetCapture (string subject, typeRegex obRegex, out int count)\t{...}\n}
    \n\n

    Привожу пример реализованной функции matchAllPatternOrder (основные моменты пояснены ниже):

    \n\n
    typePatternOrder[] matchAllPatternOrder (string subject, typeRegex obRegex, out int count)\n{\n\ttypePatternOrder[] matches;\n\tauto stdMatches = matchAll(subject, obRegex);\n \n\twhile (!stdMatches.empty) {\n\t\tmatches.length++;\n\t\tmatches[0] ~= stdMatches.front.hit;\n \n\t\tfor (int i = 1; i < stdMatches.front.length; i++) {\n\t\t\tmatches.length++;\n\t\t\tmatches[count+1] ~= stdMatches.front[i];\n\t\t}\n\t\tstdMatches.popFront();\n\t\tcount++;\n\t}\n\treturn matches;\n}
    \n\n\n

    Реализация функции matchAllSetOrder по сути ничем сильно не отличается кроме как позициями найденных строк в массиве (она даже проще). Приведу лишь реализованный вариант:

    \n\n
    typeSetOrder[] matchAllSetOrder (string subject, typeRegex obRegex, out int count)\n{\n\ttypeSetOrder[] matches;\n\tauto stdMatches = matchAll(subject, obRegex);\n \n\twhile (!stdMatches.empty) {\n\t\tfor (int i = 0; i < stdMatches.front.length; i++) {\n\t\t\tmatches.length++;\n\t\t\tmatches[count] ~= stdMatches.front[i];\n\t\t}\n\t\tstdMatches.popFront();\n\t\tcount++;\n\t}\n\treturn matches;\n}
    \n\n

    Реализация функции matchAllOffsetCapture имеет свои особенности. Найденные данные функции будем хранить в двумерном массиве картежа типов Tuple!(string, «text», int, «position»), по которому можно будет получить позицию и найденую строку. Реализация:

    \n\n
    alias typeOffsetCapture = Tuple!(string, "text", int, "position")[];\n \ntypeOffsetCapture[] matchAllOffsetCapture (string subject, typeRegex obRegex, out int count)\n\t{\n\t\ttypeOffsetCapture[] matches;\n \n\t\tauto stdMatches = matchAll(subject, obRegex);\n\t\tmatches.length = stdMatches.front.length;\n \n\t\twhile (!stdMatches.empty) {\n\t\t\tTuple!(string, "text", int, "position") match;\n\t\t\tmatch.text = stdMatches.front.hit;\n\t\t\tmatch.position = cast(int)(match.text.ptr - subject.ptr);\n\t\t\tmatches[0].length = count+1;\n\t\t\tmatches[0][count] = match;\n\t\t\tfor (int i = 1; i < stdMatches.front.length; i++) {\n\t\t\t\tmatches[i].length = matches[0].length;\n\t\t\t\tmatch.text = stdMatches.front[i];\n\t\t\t\tmatch.position = cast(int)(stdMatches.front[i].ptr - subject.ptr);\n\t\t\t\tmatches[i][count] = match;\n\t\t\t}\n\t\t\tstdMatches.popFront();\n\t\t\tcount++;\n\t\t}\n\t\treturn matches;\n\t}
    \n\n

    Единственное, что здесь важно отметить, как определяются позиции, а именно:

    \n\n
    match.position = cast(int)(match.text.ptr - subject.ptr);
    \n\n

    Позиция определяется через разность указателей позиций элементов массива. Также здесь выполняется приведение типа к int, так как многие процессоры уже использует 64-х битное представление, и указатели будут иметь тип long.

    \n

    Полная реализация модуля с тестами представлена ниже:

    \n\n
    module preg;\n \nimport std.string: format;\nimport std.typecons;\nimport std.regex: Regex, regex, ctRegex, matchFirst, matchAll;\n \n// analog of preg regex in php\n \nclass PReg\n{\n\tprivate static alias typeRegex = Regex!char;\n \n\tpublic static:\n \n\talias typePatternOrder = string[];\n\talias typeSetOrder = string[];\n\talias typeOffsetCapture = Tuple!(string, "text", int, "position")[];\n \n\ttypePatternOrder[] matchAllPatternOrder (string subject, typeRegex obRegex, out int count)\n\t{\n\t\ttypePatternOrder[] matches;\n\t\tauto stdMatches = matchAll(subject, obRegex);\n \n\t\twhile (!stdMatches.empty) {\n\t\t\tmatches.length++;\n\t\t\tmatches[0] ~= stdMatches.front.hit;\n \n\t\t\tfor (int i = 1; i < stdMatches.front.length; i++) {\n\t\t\t\tmatches.length++;\n\t\t\t\tmatches[count+1] ~= stdMatches.front[i];\n\t\t\t}\n\t\t\tstdMatches.popFront();\n\t\t\tcount++;\n\t\t}\n\t\treturn matches;\n\t}\n \n \n\ttypeSetOrder[] matchAllSetOrder (string subject, typeRegex obRegex, out int count)\n\t{\n\t\ttypeSetOrder[] matches;\n \n\t\tauto stdMatches = matchAll(subject, obRegex);\n \n\t\twhile (!stdMatches.empty) {\n\t\t\tfor (int i = 0; i < stdMatches.front.length; i++) {\n\t\t\t\tmatches.length++;\n\t\t\t\tmatches[count] ~= stdMatches.front[i];\n\t\t\t}\n\t\t\tstdMatches.popFront();\n\t\t\tcount++;\n\t\t}\n\t\treturn matches;\n\t}\n \n\ttypeOffsetCapture[] matchAllOffsetCapture (string subject, typeRegex obRegex, out int count)\n\t{\n\t\ttypeOffsetCapture[] matches;\n \n\t\tauto stdMatches = matchAll(subject, obRegex);\n\t\tmatches.length = stdMatches.front.length;\n \n\t\twhile (!stdMatches.empty) {\n\t\t\tTuple!(string, "text", int, "position") match;\n\t\t\tmatch.text = stdMatches.front.hit;\n\t\t\tmatch.position = cast(int)(match.text.ptr - subject.ptr);\n\t\t\tmatches[0].length = count+1;\n\t\t\tmatches[0][count] = match;\n\t\t\tfor (int i = 1; i < stdMatches.front.length; i++) {\n\t\t\t\tmatches[i].length = matches[0].length;\n\t\t\t\tmatch.text = stdMatches.front[i];\n\t\t\t\tmatch.position = cast(int)(stdMatches.front[i].ptr - subject.ptr);\n\t\t\t\tmatches[i][count] = match;\n\t\t\t}\n\t\t\tstdMatches.popFront();\n\t\t\tcount++;\n\t\t}\n\t\treturn matches;\n\t}\n}\n \nunittest\n{\n\timport std.stdio;\n\twriteln("test PReg.matchAll");\n \n\tint count;\n \n\tauto matches1 = PReg.matchAllPatternOrder(`one two`, regex(`(\\w)(\\w)\\w+`), count);\n\tassert(matches1[0][0] == `one`);\n\tassert(matches1[0][1] == `two`);\n\tassert(matches1[1][0] == `o`);\n\tassert(matches1[1][1] == `n`);\n\tassert(matches1[2][0] == `t`);\n\tassert(matches1[2][1] == `w`);\n\tassert(count == 2);\n \n\tauto matches2 = PReg.matchAllSetOrder(`one two`, regex(`(\\w)(\\w)\\w+`), count);\n\tassert(matches2[0][0] == `one`);\n\tassert(matches2[0][1] == `o`);\n\tassert(matches2[0][2] == `n`);\n\tassert(matches2[1][0] == `two`);\n\tassert(matches2[1][1] == `t`);\n\tassert(matches2[1][2] == `w`);\n\tassert(count == 2);\n \n\tauto matches3 = PReg.matchAllOffsetCapture(`one two`, ctRegex!(`(\\w)(\\w)\\w+`), count);\n\tassert(matches3[0][0].text == "one");\n\tassert(matches3[0][0].position == 0);\n\tassert(matches3[0][1].text == "two");\n\tassert(matches3[0][1].position == 4);\n \n\tassert(matches3[1][0].text == "o");\n\tassert(matches3[1][0].position == 0);\n\tassert(matches3[1][1].text == "t");\n\tassert(matches3[1][1].position == 4);\n \n\tassert(matches3[2][0].text == "n");\n\tassert(matches3[2][0].position == 1);\n\tassert(matches3[2][1].text == "w");\n\tassert(matches3[2][1].position == 5);\n\tassert(count == 2);\n}
    \n\n

    Надеюсь, что из этой статьи вы подчеркнули для себя что-то полезное и интересное. Всем спасибо!

    \n

    Как довести класс до рабочего состояния

    \n

    После реализации флагов обязательно добавьте тесты на три случая: нет совпадений, одно совпадение с группами и несколько совпадений с позициями. Именно на позициях чаще всего появляются ошибки, потому что PHP возвращает смещения в исходной строке, а не в подстроке после очередного поиска.

    \n
    assert(matchAll(r\"(w+)=(d+)\", \"a=1 b=22\").length == 2);\nassert(matchAll(r\"z+\", \"abc\").empty);
    \n

    В D стоит явно решить, какой формат результата нужен. PHP поддерживает несколько режимов: PREG_PATTERN_ORDER, PREG_SET_ORDER и вариант с PREG_OFFSET_CAPTURE. Если пытаться сделать всё сразу, код быстро станет запутанным. Лучше сначала реализовать простой режим, затем режим группировки по совпадениям, и только после этого добавить позиции.

    \n
    struct MatchPart\n{\n    string text;\n    ptrdiff_t offset;\n}\n\nalias MatchSet = MatchPart[][];
    \n

    Отдельно проверьте Unicode. Если шаблон и строка содержат кириллицу, важно понимать, что именно возвращается в offset: позиция в байтах или позиция в символах. PHP для PREG_OFFSET_CAPTURE возвращает байтовое смещение. Если в D вы хотите повторить поведение PHP, нужно документировать именно байтовый offset, иначе результаты будут отличаться.

    \n

    Финальный класс должен иметь маленькую поверхность API: метод без offset, метод с offset и понятный enum для порядка результата. Тогда аналог preg_match_all будет не просто копией PHP-функции, а удобным D-инструментом, который предсказуемо работает в тестах.

    ", + "contentHtml": "

    В PHP есть очень удобная функция для глобального поиска шаблона регулярного выражения в строке preg_match_all. Давайте напишем аналогичный класс статических методов для реализации этой функции с разными флагами на языке программирования D.

    \n

    Описание функции:

    \n

    ``` int preg_match_all ( string $pattern , string $subject [, array &amp;$matches [, int $flags = PREG_PATTERN_ORDER [, int $offset = 0 ]]] )

    \n
    \nФункция ищет в строке *subject* все совпадения с шаблоном pattern и помещает результат в массив *matches* в порядке, определяемом комбинацией флагов *flags*.\n После нахождения первого соответствия последующие поиски будут осуществляться не с начала строки, а от конца последнего найденного вхождения.\n\nДанная функцию очень удобна и чаще всего она применяется с третьим параметром, чтобы обработать полученный массив совпадений шаблона. В *D* подобной регулярной функции нет. Мне вообще не сильно нравятся, как реализованы регулярные выражения в *D*. Ну да ладно.\n\nПолную документацию по функции можете посмотреть здесь [http://php.net/manual/ru/function.preg-match-all.php](http://php.net/manual/ru/function.preg-match-all.php).\n\nВозможные флаги функции *preg_match_all*:\n\n- *PREG_PATTERN_ORDER* – упорядочивает результаты так, что элемент *$matches[0]* содержит массив полных вхождений шаблона, элемент *$matches[1]* содержит массив вхождений первой подмаски, и так далее;\n- *PREG_SET_ORDER* – упорядочивает результаты так, что элемент *$matches[0]* содержит первый набор вхождений, элемент *$matches[1]* содержит второй набор вхождений, и так далее;\n- *PREG_OFFSET_CAPTURE* – в случае, если этот флаг указан, для каждой найденной подстроки будет указана ее позиция в исходной строке. Необходимо помнить, что этот флаг меняет формат возвращаемого массива matches в массив, каждый элемент которого содержит массив, содержащий в индексе с номером *0* найденную подстроку, а смещение этой подстроки в параметре *subject* — в индексе *1*.\n\nНе будем заморачиваться с реализацией через передачу константы, как в *PHP,* или через шаблоны в *D*. Создадим для каждого флага *PHP*данной функции аналогичные статические методы. Параметр *offset* оставим за бортом. Класс давайте назовём *PReg*. Вырисовывается следующая структура:\n
    \n

    class PReg { public static:

    \n

    … matchAllPatternOrder (...) {...} … matchAllSetOrder (...){...} … matchAllOffsetCapture (…) {...} }

    \n
    \nКак параметры будем передавать строку для поиска (тип *string*), регулярное выражение (тип *???*) и переменную для вывода количества совпадений шаблона регулярного выражения (тип *out int*). Тип регулярного выражения можно посмотреть, чтобы не капаться в модуле, применив такую хитрость:\n
    \n

    writeln(typeof(regex(\\d+)).stringof);

    \n
    \nТакже как аргумент вы без проблем сможете передавать compile-time регулярное выражение (*ctRegex*). Для данного типа создадим алиас типа, чтобы поудобнее было.\n Функция *matchAllPatternOrde*r и *matchAllSetOrde*r будет возвращать двумерный массив строк, для них тоже создадим алиас типов (мне так кажется, что путаницы меньше). *MatchAllOffsetCapture* пока оставим на закуску.\n\nВ итоге у нас получается:\n
    \n

    class PReg { private static alias typeRegex = Regex!char;

    \n

    public static: alias typePatternOrder = string[]; alias typeSetOrder = string[];

    \n

    typePatternOrder[] matchAllPatternOrder (string subject, typeRegex obRegex, out int count) {..} typeSetOrder[] matchAllSetOrder (string subject, typeRegex obRegex, out int count){...} … matchAllOffsetCapture (string subject, typeRegex obRegex, out int count)\t{...} }

    \n
    \nПривожу пример реализованной функции *matchAllPatternOrder* (основные моменты пояснены ниже):\n
    \n

    typePatternOrder[] matchAllPatternOrder (string subject, typeRegex obRegex, out int count) { typePatternOrder[] matches; auto stdMatches = matchAll(subject, obRegex);

    \n

    while (!stdMatches.empty) { matches.length++; matches[0] ~= stdMatches.front.hit;

    \n

    for (int i = 1; i < stdMatches.front.length; i++) { matches.length++; matches[count+1] ~= stdMatches.front[i]; } stdMatches.popFront(); count++; } return matches; }

    \n
    - Функция *matchAll* осуществляет глобальный поиск шаблона регулярного выражения obRegex.\n- Цикл *while* файл перебирает найденные совпадения пока они не закончатся (проверка с помомощью *stdMatches.empty*);\n- *stdMatches.front* хранит строку и найденные подстроки;\n- *stdMatches.front.hit* возвращает всё строку входящую в найденный шаблон;\n- *stdMatches.front[i]* возвращает найденные части по позиции маски;\n- *stdMatches.popFront(*) переход к следующей найденной строке.\n\nРеализация функции *matchAllSetOrder* по сути ничем сильно не отличается кроме как позициями найденных строк в массиве (она даже проще). Приведу лишь реализованный вариант:\n
    \n

    typeSetOrder[] matchAllSetOrder (string subject, typeRegex obRegex, out int count) { typeSetOrder[] matches; auto stdMatches = matchAll(subject, obRegex);

    \n

    while (!stdMatches.empty) { for (int i = 0; i < stdMatches.front.length; i++) { matches.length++; matches[count] ~= stdMatches.front[i]; } stdMatches.popFront(); count++; } return matches; }

    \n
    \nРеализация функции *matchAllOffsetCapture* имеет свои особенности. Найденные данные функции будем хранить в двумерном массиве картежа типов *Tuple!(string, «text», int, «position»)*, по которому можно будет получить позицию и найденую строку. Реализация:\n
    \n

    alias typeOffsetCapture = Tuple!(string, "text", int, "position")[];

    \n

    typeOffsetCapture[] matchAllOffsetCapture (string subject, typeRegex obRegex, out int count) { typeOffsetCapture[] matches;

    \n

    auto stdMatches = matchAll(subject, obRegex); matches.length = stdMatches.front.length;

    \n

    while (!stdMatches.empty) { Tuple!(string, "text", int, "position") match; match.text = stdMatches.front.hit; match.position = cast(int)(match.text.ptr - subject.ptr); matches[0].length = count+1; matches[0][count] = match; for (int i = 1; i < stdMatches.front.length; i++) { matches[i].length = matches[0].length; match.text = stdMatches.front[i]; match.position = cast(int)(stdMatches.front[i].ptr - subject.ptr); matches[i][count] = match; } stdMatches.popFront(); count++; } return matches; }

    \n
    \nЕдинственное, что здесь важно отметить, как определяются позиции, а именно:\n
    \n

    match.position = cast(int)(match.text.ptr - subject.ptr);

    \n
    \nПозиция определяется через разность указателей позиций элементов массива. Также здесь выполняется приведение типа к *int*, так как многие процессоры уже использует 64-х битное представление, и указатели будут иметь тип*long*.\n\nПолная реализация модуля с тестами представлена ниже:\n
    \n

    module preg;

    \n

    import std.string: format; import std.typecons; import std.regex: Regex, regex, ctRegex, matchFirst, matchAll;

    \n

    // analog of preg regex in php

    \n

    class PReg { private static alias typeRegex = Regex!char;

    \n

    public static:

    \n

    alias typePatternOrder = string[]; alias typeSetOrder = string[]; alias typeOffsetCapture = Tuple!(string, "text", int, "position")[];

    \n

    typePatternOrder[] matchAllPatternOrder (string subject, typeRegex obRegex, out int count) { typePatternOrder[] matches; auto stdMatches = matchAll(subject, obRegex);

    \n

    while (!stdMatches.empty) { matches.length++; matches[0] ~= stdMatches.front.hit;

    \n

    for (int i = 1; i < stdMatches.front.length; i++) { matches.length++; matches[count+1] ~= stdMatches.front[i]; } stdMatches.popFront(); count++; } return matches; }

    \n

    typeSetOrder[] matchAllSetOrder (string subject, typeRegex obRegex, out int count) { typeSetOrder[] matches;

    \n

    auto stdMatches = matchAll(subject, obRegex);

    \n

    while (!stdMatches.empty) { for (int i = 0; i < stdMatches.front.length; i++) { matches.length++; matches[count] ~= stdMatches.front[i]; } stdMatches.popFront(); count++; } return matches; }

    \n

    typeOffsetCapture[] matchAllOffsetCapture (string subject, typeRegex obRegex, out int count) { typeOffsetCapture[] matches;

    \n

    auto stdMatches = matchAll(subject, obRegex); matches.length = stdMatches.front.length;

    \n

    while (!stdMatches.empty) { Tuple!(string, "text", int, "position") match; match.text = stdMatches.front.hit; match.position = cast(int)(match.text.ptr - subject.ptr); matches[0].length = count+1; matches[0][count] = match; for (int i = 1; i < stdMatches.front.length; i++) { matches[i].length = matches[0].length; match.text = stdMatches.front[i]; match.position = cast(int)(stdMatches.front[i].ptr - subject.ptr); matches[i][count] = match; } stdMatches.popFront(); count++; } return matches; } }

    \n

    unittest { import std.stdio; writeln("test PReg.matchAll");

    \n

    int count;

    \n

    auto matches1 = PReg.matchAllPatternOrder(one two, regex((\\w)(\\w)\\w+), count); assert(matches1[0][0] == one); assert(matches1[0][1] == two); assert(matches1[1][0] == o); assert(matches1[1][1] == n); assert(matches1[2][0] == t); assert(matches1[2][1] == w); assert(count == 2);

    \n

    auto matches2 = PReg.matchAllSetOrder(one two, regex((\\w)(\\w)\\w+), count); assert(matches2[0][0] == one); assert(matches2[0][1] == o); assert(matches2[0][2] == n); assert(matches2[1][0] == two); assert(matches2[1][1] == t); assert(matches2[1][2] == w); assert(count == 2);

    \n

    auto matches3 = PReg.matchAllOffsetCapture(one two, ctRegex!((\\w)(\\w)\\w+), count); assert(matches3[0][0].text == "one"); assert(matches3[0][0].position == 0); assert(matches3[0][1].text == "two"); assert(matches3[0][1].position == 4);

    \n

    assert(matches3[1][0].text == "o"); assert(matches3[1][0].position == 0); assert(matches3[1][1].text == "t"); assert(matches3[1][1].position == 4);

    \n

    assert(matches3[2][0].text == "n"); assert(matches3[2][0].position == 1); assert(matches3[2][1].text == "w"); assert(matches3[2][1].position == 5); assert(count == 2); }

    \n
    \nНадеюсь, что из этой статьи вы подчеркнули для себя что-то полезное и интересное. Всем спасибо!\n\n## Как довести класс до рабочего состояния\n\nПосле реализации флагов обязательно добавьте тесты на три случая: нет совпадений, одно совпадение с группами и несколько совпадений с позициями. Именно на позициях чаще всего появляются ошибки, потому что PHP возвращает смещения в исходной строке, а не в подстроке после очередного поиска.\n
    \n

    assert(matchAll(r"(w+)=(d+)", "a=1 b=22").length == 2); assert(matchAll(r"z+", "abc").empty);

    \n
    \nВ D стоит явно решить, какой формат результата нужен. PHP поддерживает несколько режимов: *PREG_PATTERN_ORDER*, *PREG_SET_ORDER* и вариант с *PREG_OFFSET_CAPTURE*. Если пытаться сделать всё сразу, код быстро станет запутанным. Лучше сначала реализовать простой режим, затем режим группировки по совпадениям, и только после этого добавить позиции.\n
    \n

    struct MatchPart { string text; ptrdiff_t offset; }

    \n

    alias MatchSet = MatchPart[][];

    \n
    \nОтдельно проверьте Unicode. Если шаблон и строка содержат кириллицу, важно понимать, что именно возвращается в offset: позиция в байтах или позиция в символах. PHP для *PREG_OFFSET_CAPTURE* возвращает байтовое смещение. Если в D вы хотите повторить поведение PHP, нужно документировать именно байтовый offset, иначе результаты будут отличаться.\n\nФинальный класс должен иметь маленькую поверхность API: метод без offset, метод с offset и понятный enum для порядка результата. Тогда аналог *preg_match_all* будет не просто копией PHP-функции, а удобным D-инструментом, который предсказуемо работает в тестах.
    ", "readingMinutes": 7 }, { @@ -135,7 +135,7 @@ ], "cover": "/assets/illustrations/ctfe-engine.svg", "excerpt": "В течение последних 9 месяцев велась работа над проектом под названием NewCTFE, в котором переписываются методы выполнения функций времени компиляции (СTFE) . СTFE считается одной из технологий способных изменить D. Как следует из названия, CTFE позволяет комп", - "contentHtml": "

    В течение последних 9 месяцев велась работа над проектом под названием NewCTFE, в котором переписываются методы выполнения функций времени компиляции (СTFE). СTFE считается одной из технологий способных изменить D.

    \n

    Как следует из названия, CTFE позволяет компилятору выполнять некоторые функции, когда он компилирует исходный код, в котором реализованы функции. Пока все аргументы функции доступны во время компиляции, а функция чиста (не имеет побочных эффектов), тогда функция квалифицируется как CTFE, и компилятор заменяет вызов функции результатом.

    \n\n

    Поскольку это неотъемлемая часть языка, чистые функции могут быть вычислены везде, где может находиться константа времени компиляции. Простой пример можно найти в стандартном модуле std.uri, где CTFE используется для вычисления таблицы поиска. Это выглядит так:

    \n\n
    private immutable ubyte[128] uri_flags = // indexed by character\n({\nubyte[128] uflags;\n// Compile time initialize\nuflags['#'] |= URI_Hash;\nforeach (c; 'A' .. 'Z' + 1)\n{\nuflags[c] |= URI_Alpha;\nuflags[c + 0x20] |= URI_Alpha; // lowercase letters\n}\nforeach (c; '0' .. '9' + 1) uflags[c] |= URI_Digit;\nforeach (c; ";/?:@&amp;=+$,") uflags[c] |= URI_Reserved;\nforeach (c; "-_.!~*'()") uflags[c] |= URI_Mark;\nreturn uflags;\n})();
    \n\n

    Вместо заполнения таблицы магическими значениями используется простой экспрессивный литерал функции. Это намного проще понять и отладить, чем некоторые непрозрачные статические массивы. ({ запускает функцию-литерал, а }) закрывает ее. () в конце говорит компилятору немедленно вызвать этот литерал, чтобы uri_flags стал результатом литерала.

    \n

    Функции выполняются только во время компиляции, если они необходимы. Uri_flags в приведенном выше фрагменте объявляется в области видимости модуля. Когда переменная области видимости модуля инициализируется таким образом, инициализатор должен быть доступен во время компиляции. В этом случае, поскольку инициализатор является функциональным литералом, будет предпринята попытка выполнить CTFE. Этот конкретный литерал не имеет аргументов и является чистым, поэтому попытка выполнена успешно.

    \n

    Более подробное обсуждение CTFE смотрите в статье H. S. Teoh D Wiki.

    \n

    Конечно, подобный метод может быть применен и к более сложным проблемам. Например, std.regex можно использовать со специализированным автоматом для выполнения регулярного выражения во время компиляции с использованием CTFE. Однако, как только std.regex используется с CTFE для нетривиальных паттернов, то время компиляции может стать чрезвычайно длительным (в D все, что занимает больше секунды при компиляции — избыточность :)). В конце концов, по мере усложнения паттернов, у компилятора появится нехватка памяти, и, возможно, произойдёт крах всей системы.

    \n

    Причина этого может крыться в текущей архитектуре интерпретатора CTFE. Это интерпретатор AST (абстрактного синтаксического дерева) — это означает, что он интерпретирует AST во время его обхода. Чтобы представить результат интерпретируемых выражений, он использует классы узлов DMD в AST. Это означает, что на каждое вновь встреченное выражение будет выделено один или несколько узлов AST. В ограниченном цикле интерпретатор может легко создать более 100.000.000 узлов и использовать несколько гигабайт оперативной памяти. Это может быстро израсходовать память.

    \n

    В issue 12844 имеет место проблема, что std.regex занимает более 16 ГБ ОЗУ для одного шаблона. Также есть issue 6498 , которая показывает, что выполняется простой от 0 до 10.000.000 узлов во время выполнения CTFE и что приводит к критичной нехватки памяти.

    \n

    Простое освобождение узлов не устраняет проблему, так мы точно не знаем, какие узлы необходимо освободить, и что cделает весь компилятор очень медленным из-за сборщика мусора. К счастью, есть еще один подход, который не выделяет память для каждого вновь встреченного выражения. Он включает в себя компиляцию функции в виртуальную ISA (архитектуру набора инструкций). Эта виртуальная ISA, также известная как байт-код, затем передается выделенному интерпретатору для этой ISA (в случае, когда виртуальный ISA совпадает с ISA хоста, мы называем его JIT (Just in Time) интерпретатором).

    \n

    Проект NewCTFE занимается реализацией такого интерпретатора байт-кода. Написание фактического интерпретатора (эмулятора CPU для виртуального CPU/ISA) достаточно просто. Однако компиляция кода для виртуального ISA выполняется в точности так же, как и при компиляции его в реальном ISA (хотя виртуальная ISA имеет дополнительное преимущество, которое может быть расширено для индивидуальных потребностей, но это затруднит выполнение JIT позже). Вот почему потребовался всего месяц, чтобы пучить первые простые примеры, работающие на новом движке CTFE, и почему немного более сложные из них все еще не работают даже после 9 месяцев разработки. В конце статьи вы найдете примерный график выполненной к настоящему времени работы (см. оригинал).

    \n

    Я буду выступать с презентацией на DConf 2017, где я расскажу о своем опыте внедрения движка и объясню некоторые технические детали, особенно относительно компромиссов и архитектурных решений, которые я применил. Текущая оценка состоит в том, что версия 1.0 не будет реализована к тому времени, но я буду заниматься разработкой, пока не закончу данный проект.

    \n

    Те, кто хочет отслеживать разработку, могут сделать это на форуме D. В будущем я планирую написать еще одну статью о некоторых технических деталях реализации. К тому времени, я надеюсь, что следующий список поможет пролить свет на то, как много работы по реализации NewCTFE.

    \n

    Данная статья является переводом статьи Штефана Коха, который является разработчиком sqlite-d, встроенного пакета в D для работы с sqlite, также он внес свой вклад в такие проекты, как SDC (Stupid D Compiler) и vibe.d. Он также был ответственен за 10% -ное повышение производительности в текущей реализации CTFE в D и в настоящее время пишет новый движок CTFE.

    \n

    Оригинал статьи смотрите по ссылке The D Blog/The New CTFE Engine.

    \n

    Как применять выводы на практике

    \n

    CTFE полезен там, где результат можно посчитать один раз во время компиляции: таблицы констант, парсинг небольших DSL, генерация однотипного кода, предварительная подготовка строк и проверка инвариантов. Но не стоит превращать компиляцию в полноценный runtime. Чем проще входные данные и чем меньше побочных эффектов, тем легче будет сопровождать проект.

    \n
    enum table = buildLookupTable();\n\nstatic assert(table.length == 256);
    \n

    Хороший кандидат для CTFE — функция, которая зависит только от литералов, типов и конфигурации сборки. Плохой кандидат — код, которому нужен ввод-вывод, сеть, текущее время, состояние окружения или большая внешняя база данных. Такой код лучше оставить в runtime или вынести в отдельный генератор, который запускается до сборки.

    \n

    Для контроля качества CTFE-кода полезно держать рядом static assert. Он сразу показывает, что вычисление действительно произошло на этапе компиляции и вернуло ожидаемый результат. Если compile-time функция стала слишком сложной, её стоит разбить на маленькие чистые функции: так компилятору проще, и человеку проще понять, что происходит.

    \n

    Практический вывод такой: CTFE в D — мощный инструмент, но сила его не в магии, а в переносе предсказуемых вычислений из runtime в compile-time. Используем его для констант, генерации и проверок. Не используем его как замену обычной программе.

    ", + "contentHtml": "

    В течение последних 9 месяцев велась работа над проектом под названием NewCTFE, в котором переписываются методы выполненияфункций времени компиляции (СTFE). СTFE считается одной из технологий способных изменить D.

    \n

    Как следует из названия, CTFE позволяет компилятору выполнять некоторые функции, когда он компилирует исходный код, в котором реализованы функции. Пока все аргументы функции доступны во время компиляции, а функция чиста (не имеет побочных эффектов), тогда функция квалифицируется как CTFE, и компилятор заменяет вызов функции результатом.

    \n

    Поскольку это неотъемлемая часть языка, чистые функции могут быть вычислены везде, где может находиться константа времени компиляции. Простой пример можно найти в стандартном модуле std.uri, где CTFE используется для вычисления таблицы поиска. Это выглядит так:

    \n
    private immutable ubyte[128] uri_flags = // indexed by character\n({\nubyte[128] uflags;\n// Compile time initialize\nuflags['#'] |= URI_Hash;\nforeach (c; 'A' .. 'Z' + 1)\n{\nuflags[c] |= URI_Alpha;\nuflags[c + 0x20] |= URI_Alpha; // lowercase letters\n}\nforeach (c; '0' .. '9' + 1) uflags[c] |= URI_Digit;\nforeach (c; ";/?:@&amp;=+$,") uflags[c] |= URI_Reserved;\nforeach (c; "-_.!~*'()") uflags[c] |= URI_Mark;\nreturn uflags;\n})();
    \n

    Вместо заполнения таблицы магическими значениями используется простой экспрессивный литерал функции. Это намного проще понять и отладить, чем некоторые непрозрачные статические массивы. ({ запускает функцию-литерал, а }) закрывает ее. () в конце говорит компилятору немедленно вызвать этот литерал, чтобы uri_flags стал результатом литерала.

    \n

    Функции выполняются только во время компиляции, если они необходимы. Uri_flags в приведенном выше фрагменте объявляется в области видимости модуля. Когда переменная области видимости модуля инициализируется таким образом, инициализатор должен быть доступен во время компиляции. В этом случае, поскольку инициализатор является функциональным литералом, будет предпринята попытка выполнить CTFE. Этот конкретный литерал не имеет аргументов и является чистым, поэтому попытка выполнена успешно.

    \n

    Более подробное обсуждение CTFE смотрите в статье H. S. Teoh D Wiki.

    \n

    Конечно, подобный метод может быть применен и к более сложным проблемам. Например, std.regex можно использовать со специализированным автоматом для выполнения регулярного выражения во время компиляции с использованием CTFE. Однако, как только std.regex используется с CTFE для нетривиальных паттернов, то время компиляции может стать чрезвычайно длительным (в D все, что занимает больше секунды при компиляции — избыточность :)). В конце концов, по мере усложнения паттернов, у компилятора появится нехватка памяти, и, возможно, произойдёт крах всей системы.

    \n

    Причина этого может крыться в текущей архитектуре интерпретатора CTFE. Это интерпретатор AST (абстрактного синтаксического дерева) — это означает, что он интерпретирует AST во время его обхода. Чтобы представить результат интерпретируемых выражений, он использует классы узлов DMD в AST. Это означает, что на каждое вновь встреченное выражение будет выделено один или несколько узлов AST. В ограниченном цикле интерпретатор может легко создать более 100.000.000 узлов и использовать несколько гигабайт оперативной памяти. Это может быстро израсходовать память.

    \n

    В issue 12844 имеет место проблема, что std.regex занимает более 16 ГБ ОЗУ для одного шаблона. Также есть issue 6498 , которая показывает, что выполняется простой от 0 до 10.000.000 узлов во время выполнения CTFE и что приводит к критичной нехватки памяти.

    \n

    Простое освобождение узлов не устраняет проблему, так мы точно не знаем, какие узлы необходимо освободить, и что cделает весь компилятор очень медленным из-за сборщика мусора. К счастью, есть еще один подход, который не выделяет память для каждого вновь встреченного выражения. Он включает в себя компиляцию функции в виртуальную ISA (архитектуру набора инструкций). Эта виртуальная ISA, также известная как байт-код, затем передается выделенному интерпретатору для этой ISA (в случае, когда виртуальный ISA совпадает с ISA хоста, мы называем его JIT (Just in Time) интерпретатором).

    \n

    Проект NewCTFE занимается реализацией такого интерпретатора байт-кода. Написание фактического интерпретатора (эмулятора CPU для виртуального CPU/ISA) достаточно просто. Однако компиляция кода для виртуального ISA выполняется в точности так же, как и при компиляции его в реальном ISA (хотя виртуальная ISA имеет дополнительное преимущество, которое может быть расширено для индивидуальных потребностей, но это затруднит выполнение JIT позже). Вот почему потребовался всего месяц, чтобы пучить первые простые примеры, работающие на новом движке CTFE, и почему немного более сложные из них все еще не работают даже после 9 месяцев разработки. В конце статьи вы найдете примерный график выполненной к настоящему времени работы (см. оригинал).

    \n

    Я буду выступать с презентацией на DConf 2017, где я расскажу о своем опыте внедрения движка и объясню некоторые технические детали, особенно относительно компромиссов и архитектурных решений, которые я применил. Текущая оценка состоит в том, что версия 1.0 не будет реализована к тому времени, но я буду заниматься разработкой, пока не закончу данный проект.

    \n

    Те, кто хочет отслеживать разработку, могут сделать это на форуме D. В будущем я планирую написать еще одну статью о некоторых технических деталях реализации. К тому времени, я надеюсь, что следующий список поможет пролить свет на то, как много работы по реализации NewCTFE.

    \n

    Данная статья является переводом статьи Штефана Коха, который является разработчиком sqlite-d, встроенного пакета в D для работы с sqlite, также он внес свой вклад в такие проекты, как SDC (Stupid D Compiler) и vibe.d. Он также был ответственен за 10% -ное повышение производительности в текущей реализации CTFE в D и в настоящее время пишет новый движок CTFE.

    \n

    Оригинал статьи смотрите по ссылке The D Blog/The New CTFE Engine.

    \n

    Как применять выводы на практике

    \n

    CTFE полезен там, где результат можно посчитать один раз во время компиляции: таблицы констант, парсинг небольших DSL, генерация однотипного кода, предварительная подготовка строк и проверка инвариантов. Но не стоит превращать компиляцию в полноценный runtime. Чем проще входные данные и чем меньше побочных эффектов, тем легче будет сопровождать проект.

    \n
    enum table = buildLookupTable();\n\nstatic assert(table.length == 256);
    \n

    Хороший кандидат для CTFE — функция, которая зависит только от литералов, типов и конфигурации сборки. Плохой кандидат — код, которому нужен ввод-вывод, сеть, текущее время, состояние окружения или большая внешняя база данных. Такой код лучше оставить в runtime или вынести в отдельный генератор, который запускается до сборки.

    \n

    Для контроля качества CTFE-кода полезно держать рядом static assert. Он сразу показывает, что вычисление действительно произошло на этапе компиляции и вернуло ожидаемый результат. Если compile-time функция стала слишком сложной, её стоит разбить на маленькие чистые функции: так компилятору проще, и человеку проще понять, что происходит.

    \n

    Практический вывод такой: CTFE в D — мощный инструмент, но сила его не в магии, а в переносе предсказуемых вычислений из runtime в compile-time. Используем его для констант, генерации и проверок. Не используем его как замену обычной программе.

    ", "readingMinutes": 6 }, { diff --git a/web/package.json b/web/package.json index 658053b..6ca30da 100644 --- a/web/package.json +++ b/web/package.json @@ -4,6 +4,7 @@ "private": true, "scripts": { "dev": "next dev", + "admin:posts": "node ../local-admin/posts-admin.mjs", "build": "next build", "start": "next start" },