diff --git a/packages/blocks/.stylelintrc b/packages/blocks/.stylelintrc new file mode 100644 index 00000000000..8ac62f8d0f9 --- /dev/null +++ b/packages/blocks/.stylelintrc @@ -0,0 +1,14 @@ +{ + "extends": ["stylelint-config-idiomatic-order"], + "plugins": ["stylelint-prettier"], + "overrides": [ + { + "files": ["**/*.scss"], + "customSyntax": "postcss-scss" + } + ], + "rules": { + "prettier/prettier": true, + "order/properties-alphabetical-order": null + } +} diff --git a/packages/blocks/Text/index.ts b/packages/blocks/Text/index.ts index af7e409602f..4181d1cf907 100644 --- a/packages/blocks/Text/index.ts +++ b/packages/blocks/Text/index.ts @@ -1,4 +1,5 @@ import React from 'react'; +import config from '@plone/registry'; const TextBlockInfo = { id: 'slate', @@ -10,6 +11,15 @@ const TextBlockInfo = { () => import(/* webpackChunkName: "plone-blocks" */ './TextBlockEdit'), ), category: 'text', + tocEntry: (block = {}) => { + const { value, override_toc, entry_text, level, plaintext } = block; + const type = value?.[0]?.type; + return override_toc && level + ? [parseInt(level.slice(1)), entry_text] + : config.settings.slate.topLevelTargetElements.includes(type) + ? [parseInt(type.slice(1)), plaintext] + : null; + }, }; export default TextBlockInfo; diff --git a/packages/blocks/ToC/ToCBlockView.tsx b/packages/blocks/ToC/ToCBlockView.tsx new file mode 100644 index 00000000000..201cc6fa6a8 --- /dev/null +++ b/packages/blocks/ToC/ToCBlockView.tsx @@ -0,0 +1,191 @@ +import { useMemo } from 'react'; +import clsx from 'clsx'; +import config from '@plone/registry'; +import type { BlocksFormData, BlockViewProps, Content } from '@plone/types'; +import { getBlocksFieldName } from '@plone/helpers'; +import './styles/ToC.css'; + +export const getBlocksTocEntries = ( + properties: Content, + tocData: BlocksFormData, +): ReturnedToCEntries => { + const blocksFieldName = getBlocksFieldName(properties); + const blocksLayoutFieldName = getBlocksFieldName(properties, 'blocks_layout'); + + const blocks = properties[blocksFieldName]; + const blocks_layout = properties[blocksLayoutFieldName]; + + const levels = + tocData.levels?.length > 0 + ? tocData.levels.map((l) => parseInt(l.slice(1))) + : [1, 2, 3, 4, 5, 6]; + let rootLevel = Infinity; + let blocksFormEntries = []; + const tocEntries = {}; + const tocEntriesLayout = []; + + blocks_layout.items.forEach((id) => { + const block = blocks[id]; + const blockConfig = config.blocks.blocksConfig[block['@type']]; + + if (!block || !blockConfig) { + return null; + } + if (!blockConfig.tocEntries && !blockConfig.tocEntry) { + return null; + } + + const blockTocEntry = blockConfig.tocEntry?.(block, tocData); + + const blockTocEntries = [ + ...(blockConfig.tocEntries?.(block, tocData) || + (blockTocEntry ? [blockTocEntry] : [])), + ]; + + blocksFormEntries = [...blocksFormEntries, ...blockTocEntries]; + + blockTocEntries.forEach((entry, index) => { + const tocEntryId = `${id}-${index}`; + const level = entry[0]; + const title = entry[1]; + const items = []; + if (!level || !levels.includes(level)) return; + tocEntriesLayout.push(tocEntryId); + tocEntries[tocEntryId] = { + level, + title: title || block.plaintext, + items, + id: tocEntryId, + }; + if (level < rootLevel) { + rootLevel = level; + } + }); + }); + + return { + rootLevel, + blocksFormEntries, + tocEntries, + tocEntriesLayout, + }; +}; + +const ToCBlockView = (props: BlockViewProps) => { + const { data, blocksConfig } = props; + + const title = data.title && !data.hide_title ? data.title : ''; + const metadata = props.metadata || props.properties; + const blocksFieldName = getBlocksFieldName(metadata); + const variation = (blocksConfig.toc.variations || []).find( + (v) => v.id === data.variation, + ); + const Renderer = variation?.view; + + const levels = useMemo( + () => + data.levels?.length > 0 + ? data.levels.map((l) => parseInt(l.slice(1))) + : [1, 2, 3, 4, 5, 6], + [data], + ); + + const tocEntries = useMemo(() => { + const entries = []; + let prevEntry: Partial = {}; + const { rootLevel, tocEntries, tocEntriesLayout } = getBlocksTocEntries( + metadata, + data, + ); + + tocEntriesLayout.forEach((id) => { + const block = metadata[blocksFieldName][id]; + if (typeof block === 'undefined') { + return null; + } + if (!config.blocks.blocksConfig[block['@type']]?.tocEntry) return null; + const entry = config.blocks.blocksConfig[block['@type']]?.tocEntry( + block, + data, + ); + + if (entry) { + const level = entry[0]; + const title = entry[1]; + const items = []; + if (!title?.trim() && !block.plaintext?.trim()) return; + if (!level || !levels.includes(level)) return; + tocEntriesLayout.push(id); + tocEntries[id] = { + level, + title: title || block.plaintext, + items, + id, + override_toc: block.override_toc, + plaintext: block.plaintext, + }; + } + }); + + tocEntriesLayout.forEach((id) => { + const entry = tocEntries[id]; + if (entry.level === rootLevel) { + entries.push(entry); + prevEntry = entry; + return; + } + if (!prevEntry.id) return; + if (entry.level > prevEntry.level) { + entry.parentId = prevEntry.id; + (prevEntry.items || []).push(entry); + prevEntry = entry; + } else if (entry.level < prevEntry.level) { + let parent = tocEntries[prevEntry.parentId]; + while (entry.level <= parent.level) { + parent = tocEntries[parent.parentId]; + } + entry.parentId = parent.id; + parent.items.push(entry); + prevEntry = entry; + } else { + entry.parentId = prevEntry.parentId; + tocEntries[prevEntry.parentId].items.push(entry); + prevEntry = entry; + } + }); + + return entries; + }, [data, levels, metadata, blocksFieldName]); + + return tocEntries.length > 0 ? ( + + ) : null; +}; + +export interface ToCEntry { + id: string; + level: number; + title: string; + items: ToCEntry[]; + override_toc?: boolean; + plaintext?: string; + parentId?: string; +} + +interface ReturnedToCEntries { + tocEntries: Record; + rootLevel: number; + blocksFormEntries: Array<[number, string]>; + tocEntriesLayout: string[]; +} + +export default ToCBlockView; diff --git a/packages/blocks/ToC/index.ts b/packages/blocks/ToC/index.ts new file mode 100644 index 00000000000..d2e721fb2db --- /dev/null +++ b/packages/blocks/ToC/index.ts @@ -0,0 +1,17 @@ +import React from 'react'; +import ToCVariations from './variations'; + +const ToCBlockInfo = { + id: 'toc', + title: 'Table of Contents', + view: React.lazy( + () => import(/* webpackChunkName: "plone-blocks" */ './ToCBlockView'), + ), + // edit: React.lazy( + // () => import(/* webpackChunkName: "plone-blocks" */ './ToCBlockEdit'), + // ), + variations: ToCVariations, + category: 'common', +}; + +export default ToCBlockInfo; diff --git a/packages/blocks/ToC/styles/ToC.css b/packages/blocks/ToC/styles/ToC.css new file mode 100644 index 00000000000..f0175d4aad0 --- /dev/null +++ b/packages/blocks/ToC/styles/ToC.css @@ -0,0 +1,32 @@ +a { + color: #007eb1; +} + +a:hover { + color: #006b96; +} + +ol.ui.list, +.ui.ordered.list, +.ui.ordered.list .list, +ol.ui.list ol { + margin-left: 1.25rem; + counter-reset: ordered; + list-style-type: decimal; +} + +ul.ui.list, +.ui.bulleted.list, +.ui.bulleted.list .list, +ul.ui.list ul { + list-style-type: disc; +} + +ul.ui.list, +.ui.bulleted.list, +ol.ui.list, +.ui.ordered.list, +.ui.ordered.list .list, +ol.ui.list ol { + margin-left: 1.25rem; +} diff --git a/packages/blocks/ToC/variations/DefaultToCRenderer.tsx b/packages/blocks/ToC/variations/DefaultToCRenderer.tsx new file mode 100644 index 00000000000..31b53867bb0 --- /dev/null +++ b/packages/blocks/ToC/variations/DefaultToCRenderer.tsx @@ -0,0 +1,67 @@ +import { clsx } from 'clsx'; +import Slugger from 'github-slugger'; +import { Link } from '@plone/components'; +import { normalizeString } from '@plone/helpers'; +import type { BlocksFormData } from '@plone/types'; +import type { ToCEntry } from '../ToCBlockView'; + +const slugger = new Slugger(); + +const RenderListItems = ({ items, data }) => { + return items.map((item) => { + const { id, level, title, override_toc, plaintext } = item; + const slug = override_toc + ? slugger.slug(normalizeString(plaintext)) + : slugger.slug(normalizeString(title)) || id; + const List = data.ordered ? 'ol' : 'ul'; + + return ( + item && ( +
  • + {title} + {item.items?.length > 0 && ( + + + + )} +
  • + ) + ); + }); +}; + +const View = ({ data, tocEntries }: DefaultToCProps) => { + const List = data.ordered ? 'ol' : 'ul'; + + return ( + <> + {data.title && !data.hide_title ?

    {data.title}

    : ''} + + + + + ); +}; + +interface DefaultToCProps { + data: BlocksFormData; + tocEntries: ToCEntry[]; +} + +export default View; diff --git a/packages/blocks/ToC/variations/index.ts b/packages/blocks/ToC/variations/index.ts new file mode 100644 index 00000000000..a4f9f32d487 --- /dev/null +++ b/packages/blocks/ToC/variations/index.ts @@ -0,0 +1,12 @@ +import DefaultToCRenderer from './DefaultToCRenderer'; + +const ToCVariations = [ + { + id: 'default', + title: 'Listing (default)', + view: DefaultToCRenderer, + isDefault: true, + }, +]; + +export default ToCVariations; diff --git a/packages/blocks/index.ts b/packages/blocks/index.ts index fe6cd1a0402..950633948d0 100644 --- a/packages/blocks/index.ts +++ b/packages/blocks/index.ts @@ -4,6 +4,7 @@ import TitleBlockInfo from './Title'; import TextBlockInfo from './Text'; import ImageBlockInfo from './Image'; import TeaserBlockInfo from './Teaser'; +import ToCBlockInfo from './ToC'; export default function install(config: ConfigType) { config.settings.slate = slate; @@ -17,6 +18,7 @@ export default function install(config: ConfigType) { config.blocks.blocksConfig.slate = TextBlockInfo; config.blocks.blocksConfig.image = ImageBlockInfo; config.blocks.blocksConfig.teaser = TeaserBlockInfo; + config.blocks.blocksConfig.toc = ToCBlockInfo; return config; } diff --git a/packages/blocks/news/7534.feature b/packages/blocks/news/7534.feature new file mode 100644 index 00000000000..781d44a5486 --- /dev/null +++ b/packages/blocks/news/7534.feature @@ -0,0 +1 @@ +Added TOC Block with Default Renderer @Catherine358 \ No newline at end of file diff --git a/packages/blocks/package.json b/packages/blocks/package.json index b9f9fab0a47..3635588dd03 100644 --- a/packages/blocks/package.json +++ b/packages/blocks/package.json @@ -51,7 +51,8 @@ "@plone/components": "workspace:*", "@plone/plate": "workspace:*", "@plone/registry": "workspace:*", - "clsx": "^2.1.1" + "clsx": "^2.1.1", + "github-slugger": "^2.0.0" }, "devDependencies": { "@plone/helpers": "workspace:*", diff --git a/packages/helpers/src/blocks.ts b/packages/helpers/src/blocks.ts index 34a10f4ba22..d20d277ebe3 100644 --- a/packages/helpers/src/blocks.ts +++ b/packages/helpers/src/blocks.ts @@ -7,3 +7,14 @@ export function hasBlocksData(content: Content) { ) !== undefined ); } + +export function getBlocksFieldName( + props: Content, + suffix: string = 'blocks', +): string | null { + return ( + Object.keys(props).find( + (key) => key !== 'volto.blocks' && key.endsWith(suffix), + ) || null + ); +} diff --git a/packages/helpers/src/index.ts b/packages/helpers/src/index.ts index 8aa5f5ec409..f7a008c3382 100644 --- a/packages/helpers/src/index.ts +++ b/packages/helpers/src/index.ts @@ -3,3 +3,4 @@ export * from './atoms'; export * from './blocks'; export * from './flattenToAppURL'; export * from './languageMap'; +export * from './utils'; diff --git a/packages/helpers/src/utils.ts b/packages/helpers/src/utils.ts new file mode 100644 index 00000000000..8104ef96339 --- /dev/null +++ b/packages/helpers/src/utils.ts @@ -0,0 +1,3 @@ +export function normalizeString(str: string): string { + return str.normalize('NFD').replace(/\p{Diacritic}/gu, ''); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5a43f56f74c..2f0a745a3fc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -302,6 +302,9 @@ importers: clsx: specifier: ^2.1.1 version: 2.1.1 + github-slugger: + specifier: ^2.0.0 + version: 2.0.0 react: specifier: ^19.1.0 version: 19.1.1 @@ -6643,6 +6646,9 @@ packages: git-url-parse@16.1.0: resolution: {integrity: sha512-cPLz4HuK86wClEW7iDdeAKcCVlWXmrLpb2L+G9goW0Z1dtpNS6BXXSOckUTlJT/LDQViE1QZKstNORzHsLnobw==} + github-slugger@2.0.0: + resolution: {integrity: sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==} + glob-parent@5.1.2: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} engines: {node: '>= 6'} @@ -16401,6 +16407,8 @@ snapshots: dependencies: git-up: 8.1.1 + github-slugger@2.0.0: {} + glob-parent@5.1.2: dependencies: is-glob: 4.0.3