From 86733cb7f61125923162e64e479cfdc30500eb31 Mon Sep 17 00:00:00 2001 From: Angelos Chalaris Date: Sat, 29 Apr 2023 20:45:58 +0300 Subject: [PATCH 01/44] Expect language configs --- src/blocks/extractor/index.js | 62 +++++++++----------------- src/blocks/extractor/markdownParser.js | 8 ++-- 2 files changed, 23 insertions(+), 47 deletions(-) diff --git a/src/blocks/extractor/index.js b/src/blocks/extractor/index.js index 4872111a3..2969e4cf2 100644 --- a/src/blocks/extractor/index.js +++ b/src/blocks/extractor/index.js @@ -1,3 +1,4 @@ +/* eslint-disable no-unused-vars */ import pathSettings from 'settings/paths'; import { Logger } from 'blocks/utilities/logger'; import { Content } from 'blocks/utilities/content'; @@ -26,7 +27,7 @@ export class Extractor { const contentConfigs = Extractor.extractContentConfigs(contentDir); const collectionConfigs = Extractor.extractCollectionConfigs(contentDir); const authors = Extractor.extractAuthors(contentDir); - const languageData = Extractor.processLanguageData(contentConfigs); + const languageData = Extractor.extractLanguageData(contentDir); const snippets = await Extractor.extractSnippets( contentDir, contentConfigs, @@ -39,7 +40,6 @@ export class Extractor { const data = { repositories: contentConfigs.map(config => { // Exclude specific keys - /* eslint-disable no-unused-vars */ const { dirName, tagIcons, @@ -52,7 +52,6 @@ export class Extractor { references, ...rest } = config; - /* eslint-enable no-unused-vars */ const language = rawLanguage && rawLanguage.long ? rawLanguage.long.toLowerCase() @@ -70,13 +69,8 @@ export class Extractor { snippets, authors, languages: [...languageData].map(([id, data]) => { - const { language, shortCode, languageLiteral } = data; - return { - id, - long: language, - short: shortCode, - name: languageLiteral, - }; + const { references, ...restData } = data; + return { ...restData }; }), tags: tagData, collectionListingConfig: Object.entries(collectionListing).reduce( @@ -150,39 +144,23 @@ export class Extractor { return authors; }; - static processLanguageData = contentConfigs => { + static extractLanguageData = contentDir => { const logger = new Logger('Extractor.extractLanguageData'); - logger.log('Processing language data'); - const languageData = contentConfigs.reduce( - (acc, config) => { - if ( - config.language && - config.language.long && - !acc.has(config.language.long) - ) { - acc.set(config.language.long.toLowerCase(), { - language: config.language.long.toLowerCase(), - shortCode: config.language.short, - languageLiteral: config.language.long, - tags: {}, - references: new Map(Object.entries(config.references || {})), - }); - } - return acc; - }, - new Map([ - [ - 'html', - { - language: 'html', - shortCode: 'html', - languageLiteral: 'HTML', - references: new Map(), - }, - ], - ]) - ); - logger.success('Finished processing language data'); + logger.log('Extracting language data'); + const languageData = YAMLHandler.fromGlob( + `${contentDir}/configs/languages/*.yaml` + ).reduce((acc, language) => { + const { short, long, name, references = {} } = language; + acc.set(long, { + id: long, + long, + short, + name, + references: new Map(Object.entries(references)), + }); + return acc; + }, new Map()); + logger.success('Finished extracting language data'); return languageData; }; diff --git a/src/blocks/extractor/markdownParser.js b/src/blocks/extractor/markdownParser.js index e6c5a23f8..d6d9ad999 100644 --- a/src/blocks/extractor/markdownParser.js +++ b/src/blocks/extractor/markdownParser.js @@ -156,7 +156,7 @@ export class MarkdownParser { if (referenceKeys && referenceKeys.length > 0) referenceKeys.forEach(key => { const languageObject = [...languageData.values()].find( - l => l.shortCode === key + l => l.short === key ); if (languageObject) languageObjects[key] = languageObject; }); @@ -173,11 +173,9 @@ export class MarkdownParser { const languageObject = isText && languageData && languageData.length - ? [...languageData.values()].find(l => l.shortCode === languageName) + ? [...languageData.values()].find(l => l.short === languageName) : null; - const languageStringLiteral = languageObject - ? languageObject.languageLiteral - : ''; + const languageStringLiteral = languageObject ? languageObject.name : ''; if (languageObject && !languageObjects[languageName]) languageObjects[languageName] = languageObject; From f0879faaad13c455da9a8cf57ce987569439bd9f Mon Sep 17 00:00:00 2001 From: Angelos Chalaris Date: Sat, 29 Apr 2023 21:07:49 +0300 Subject: [PATCH 02/44] Simplify language linking --- src/blocks/extractor/index.js | 31 +++++++++---------------------- src/blocks/schema.js | 5 ----- src/test/fixtures/content.js | 4 ---- 3 files changed, 9 insertions(+), 31 deletions(-) diff --git a/src/blocks/extractor/index.js b/src/blocks/extractor/index.js index 2969e4cf2..94c1a55f2 100644 --- a/src/blocks/extractor/index.js +++ b/src/blocks/extractor/index.js @@ -24,10 +24,13 @@ export class Extractor { static extract = async () => { if (process.env.NODE_ENV === 'production') await Content.update(); const { rawContentPath: contentDir } = pathSettings; - const contentConfigs = Extractor.extractContentConfigs(contentDir); + const languageData = Extractor.extractLanguageData(contentDir); + const contentConfigs = Extractor.extractContentConfigs( + contentDir, + languageData + ); const collectionConfigs = Extractor.extractCollectionConfigs(contentDir); const authors = Extractor.extractAuthors(contentDir); - const languageData = Extractor.extractLanguageData(contentDir); const snippets = await Extractor.extractSnippets( contentDir, contentConfigs, @@ -44,10 +47,7 @@ export class Extractor { dirName, tagIcons, slugPrefix, - secondLanguage, - optionalLanguage, language: rawLanguage, - otherLanguages: rawOtherLanguages, tagMetadata, references, ...rest @@ -56,13 +56,9 @@ export class Extractor { rawLanguage && rawLanguage.long ? rawLanguage.long.toLowerCase() : null; - const otherLanguages = rawOtherLanguages.length - ? rawOtherLanguages.map(lang => lang.long.toLowerCase()) - : null; return { ...rest, language, - otherLanguages, }; }), collections: collectionConfigs, @@ -86,22 +82,17 @@ export class Extractor { return data; }; - static extractContentConfigs = contentDir => { + static extractContentConfigs = (contentDir, languageData) => { const logger = new Logger('Extractor.extractContentConfigs'); logger.log('Extracting content configurations'); const configs = YAMLHandler.fromGlob( `${contentDir}/configs/repos/*.yaml` ).map(config => { - const language = config.language || {}; - let otherLanguages = []; - if (config.secondLanguage) otherLanguages.push(config.secondLanguage); - if (config.optionalLanguage) otherLanguages.push(config.optionalLanguage); - if (otherLanguages.length) language.otherLanguages = otherLanguages; + const language = languageData.get(config.language) || {}; return { ...config, language, - otherLanguages, id: `${config.dirName}`, slugPrefix: `${config.slug}/s`, }; @@ -260,12 +251,8 @@ export class Extractor { fileName.slice(0, -3) )}`; const tags = rawTags.map(tag => tag.toLowerCase()); - const hasOptionalLanguage = Boolean( - config.id !== '30css' && - !config.isBlog && - config.optionalLanguage && - config.optionalLanguage.short - ); + // TODO: Temporary fix to get rid of a previous workaround + const hasOptionalLanguage = config.id === '30react'; const languageKeys = config.id === '30blog' diff --git a/src/blocks/schema.js b/src/blocks/schema.js index e83e5b769..8ea40cd91 100644 --- a/src/blocks/schema.js +++ b/src/blocks/schema.js @@ -16,11 +16,6 @@ export const schema = { to: { model: 'Language', name: 'repositories' }, type: 'manyToOne', }, - { - from: { model: 'Repository', name: 'otherLanguages' }, - to: { model: 'Language', name: 'secondaryRepositories' }, - type: 'manyToMany', - }, { from: { model: 'Tag', name: 'repository' }, to: { model: 'Repository', name: 'tags' }, diff --git a/src/test/fixtures/content.js b/src/test/fixtures/content.js index 719c1937f..33c4dc0f8 100644 --- a/src/test/fixtures/content.js +++ b/src/test/fixtures/content.js @@ -13,7 +13,6 @@ export const repo30blog = { 'Discover dozens of programming articles, covering a wide variety of topics and technologies.', id: '30blog', language: null, - otherLanguages: null, icon: 'blog', }; @@ -29,7 +28,6 @@ export const repo30code = { 'Browse a wide variety of ES6 helper functions, including array operations, DOM manipulation, algorithms and Node.js utilities.', id: '30code', language: 'javascript', - otherLanguages: null, icon: 'js', }; @@ -45,7 +43,6 @@ export const repo30css = { 'A snippet collection of interactive CSS3 examples, covering layouts, styling, animation and user interactions.', id: '30css', language: 'css', - otherLanguages: ['html', 'javascript'], icon: 'css', }; @@ -61,7 +58,6 @@ export const repo30react = { 'Discover function components and reusable hooks for React 16.', id: '30react', language: 'react', - otherLanguages: ['css'], icon: 'react', }; From 03eb39c172b02f0b9967e0808d775119fe4ee7ae Mon Sep 17 00:00:00 2001 From: Angelos Chalaris Date: Sat, 29 Apr 2023 22:50:47 +0300 Subject: [PATCH 03/44] Sort typeMatcher matches by popularity --- src/blocks/application.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/blocks/application.js b/src/blocks/application.js index c26e6f59e..c6b68905e 100644 --- a/src/blocks/application.js +++ b/src/blocks/application.js @@ -459,7 +459,7 @@ export class Application { const collectionRec = Collection.createRecord(rest); if (snippetIds && snippetIds.length) collectionRec.snippets = snippetIds; if (typeMatcher) - collectionRec.snippets = Snippet.records + collectionRec.snippets = Snippet.records.listedByPopularity .where(snippet => snippet.type === typeMatcher) .flatPluck('id'); const slugPrefix = `/${collection.slug}`; From 7229fc68d03772e6935a5661ba86515c714320e3 Mon Sep 17 00:00:00 2001 From: Angelos Chalaris Date: Sat, 29 Apr 2023 22:57:56 +0300 Subject: [PATCH 04/44] Rename template to type in Page --- src/blocks/application.js | 16 ++++++++-------- src/blocks/models/page.js | 21 ++++++++++----------- 2 files changed, 18 insertions(+), 19 deletions(-) diff --git a/src/blocks/application.js b/src/blocks/application.js index c6b68905e..cfe85aa97 100644 --- a/src/blocks/application.js +++ b/src/blocks/application.js @@ -494,7 +494,7 @@ export class Application { const { id } = snippet; Page.createRecord({ id: `snippet_${id}`, - template: 'SnippetPage', + type: 'snippet', relatedRecordId: id, }); }); @@ -510,7 +510,7 @@ export class Application { for (let pageSnippets of snippetIterator) { Page.createRecord({ id: `listing_${id}_${pageCounter}`, - template: 'ListingPage', + type: 'listing', relatedRecordId: id, [itemsName]: pageSnippets.flatPluck('id'), pageNumber: pageCounter, @@ -521,36 +521,36 @@ export class Application { // Populate static pages Page.createRecord({ id: 'static_404', - template: 'NotFoundPage', + type: 'notfound', slug: '/404', staticPriority: 0, }); Page.createRecord({ id: 'static_about', - template: 'StaticPage', + type: 'static', slug: '/about', staticPriority: 0.25, }); Page.createRecord({ id: 'static_cookies', - template: 'StaticPage', + type: 'static', slug: '/cookies', staticPriority: 0.25, }); Page.createRecord({ id: 'static_faq', - template: 'StaticPage', + type: 'static', slug: '/faq', staticPriority: 0.25, }); Page.createRecord({ id: 'static_search', - template: 'SearchPage', + type: 'search', slug: '/search', staticPriority: 0.25, }); // Populate the home page - Page.createRecord({ id: 'home', template: 'HomePage' }); + Page.createRecord({ id: 'home', type: 'home' }); logger.success('Populating dataset complete.'); } diff --git a/src/blocks/models/page.js b/src/blocks/models/page.js index 4ea0693c1..88bbb2933 100644 --- a/src/blocks/models/page.js +++ b/src/blocks/models/page.js @@ -12,23 +12,22 @@ const TOP_COLLECTION_CHIPS = 8; export const page = { name: 'Page', fields: [ - { name: 'template', type: 'stringRequired' }, + { name: 'type', type: 'stringRequired' }, { name: 'relatedRecordId', type: 'string' }, { name: 'pageNumber', type: 'number' }, { name: 'slug', type: 'string' }, { name: 'staticPriority', type: 'number' }, ], properties: { - isStatic: page => - ['StaticPage', 'NotFoundPage', 'SearchPage'].includes(page.template), + isStatic: page => ['static', 'notfound', 'search'].includes(page.type), isCollectionsListing: page => page.id.startsWith('listing_collections'), - isSnippet: page => page.template === 'SnippetPage', - isListing: page => page.template === 'ListingPage', - isHome: page => page.template === 'HomePage', + isSnippet: page => page.type === 'snippet', + isListing: page => page.type === 'listing', + isHome: page => page.type === 'home', isUnlisted: page => (page.isSnippet ? !page.data.isListed : false), isPublished: page => (page.isSnippet ? page.data.isPublished : true), isIndexable: page => { - if (['NotFoundPage', 'SearchPage'].includes(page.template)) return false; + if (['notfound', 'search'].includes(page.type)) return false; if (!page.isSnippet) return true; return page.data.isPublished; }, @@ -188,7 +187,7 @@ export const page = { items: context.snippetList, }); } - if (page.template === 'SearchPage') { + if (page.type === 'search') { const sortedSnippets = Snippet.records.listedByPopularity; context.searchIndex = [ ...ListingPreviewSerializer.serializeArray( @@ -251,14 +250,14 @@ export const page = { }, }, scopes: { - listed: page => !page.isUnlisted && page.template !== 'NotFoundPage', + listed: page => !page.isUnlisted && page.type !== 'notfound', indexable: { matcher: page => page.isIndexable, sorter: (a, b) => b.priority - a.priority, }, published: page => page.isPublished, feedEligible: { - matcher: page => page.template === 'SnippetPage' && !page.isUnlisted, + matcher: page => page.type === 'snippet' && !page.isUnlisted, sorter: (a, b) => b.data.dateModified - a.data.dateModified, }, snippets: page => page.isSnippet, @@ -266,7 +265,7 @@ export const page = { listing: page => page.isListing, static: page => page.isStatic, home: page => page.isHome, - search: page => page.template === 'SearchPage', + search: page => page.type === 'search', collections: page => page.isCollectionsListing, }, }; From fbefe40fffd1227a3cb379d6e373ce26f5250e85 Mon Sep 17 00:00:00 2001 From: Angelos Chalaris Date: Sat, 29 Apr 2023 23:18:25 +0300 Subject: [PATCH 05/44] Simplify static page handling --- src/blocks/application.js | 17 +++++++++++------ src/blocks/models/listing.js | 1 + src/blocks/models/page.js | 13 +++++-------- src/test/blocks/schema.test.js | 11 +++-------- 4 files changed, 20 insertions(+), 22 deletions(-) diff --git a/src/blocks/application.js b/src/blocks/application.js index cfe85aa97..72e943f22 100644 --- a/src/blocks/application.js +++ b/src/blocks/application.js @@ -520,37 +520,42 @@ export class Application { }); // Populate static pages Page.createRecord({ - id: 'static_404', + id: '404', type: 'notfound', slug: '/404', staticPriority: 0, }); Page.createRecord({ - id: 'static_about', + id: 'about', type: 'static', slug: '/about', staticPriority: 0.25, }); Page.createRecord({ - id: 'static_cookies', + id: 'cookies', type: 'static', slug: '/cookies', staticPriority: 0.25, }); Page.createRecord({ - id: 'static_faq', + id: 'faq', type: 'static', slug: '/faq', staticPriority: 0.25, }); Page.createRecord({ - id: 'static_search', + id: 'search', type: 'search', slug: '/search', staticPriority: 0.25, }); // Populate the home page - Page.createRecord({ id: 'home', type: 'home' }); + Page.createRecord({ + id: 'home', + type: 'static', + slug: '/', + staticPriority: 1.0, + }); logger.success('Populating dataset complete.'); } diff --git a/src/blocks/models/listing.js b/src/blocks/models/listing.js index abb4f9a23..a19921a16 100644 --- a/src/blocks/models/listing.js +++ b/src/blocks/models/listing.js @@ -41,6 +41,7 @@ export const listing = { listing.parent ? listing.siblings.except(listing.id) : [], // Used to determine the order of listings in the search index. ranking: listing => { + if (listing.isCollections) return 0.8; const rankingValue = Ranker.rankIndexableContent( listing.indexableContent ); diff --git a/src/blocks/models/page.js b/src/blocks/models/page.js index 88bbb2933..91d633284 100644 --- a/src/blocks/models/page.js +++ b/src/blocks/models/page.js @@ -19,28 +19,25 @@ export const page = { { name: 'staticPriority', type: 'number' }, ], properties: { - isStatic: page => ['static', 'notfound', 'search'].includes(page.type), + isStatic: page => page.type === 'static', isCollectionsListing: page => page.id.startsWith('listing_collections'), isSnippet: page => page.type === 'snippet', isListing: page => page.type === 'listing', - isHome: page => page.type === 'home', + isHome: page => page.id === 'home', isUnlisted: page => (page.isSnippet ? !page.data.isListed : false), isPublished: page => (page.isSnippet ? page.data.isPublished : true), isIndexable: page => { - if (['notfound', 'search'].includes(page.type)) return false; + if (['404', 'search'].includes(page.id)) return false; if (!page.isSnippet) return true; return page.data.isPublished; }, priority: page => { - if (page.isHome) return 1.0; - if (page.isCollectionsListing) return 0.65; if (page.isSnippet) return +(page.data.ranking * 0.85).toFixed(2); if (page.isListing) return page.data.pageRanking(page.pageNumber); if (page.isStatic) return page.staticPriority; return 0.3; }, relRoute: page => { - if (page.isHome) return '/'; if (page.isSnippet) return page.data.slug; if (page.isListing) return `${page.data.slugPrefix}/p/${page.pageNumber}`; if (page.isStatic) return page.slug; @@ -250,7 +247,7 @@ export const page = { }, }, scopes: { - listed: page => !page.isUnlisted && page.type !== 'notfound', + listed: page => !page.isUnlisted && page.id !== '404', indexable: { matcher: page => page.isIndexable, sorter: (a, b) => b.priority - a.priority, @@ -265,7 +262,7 @@ export const page = { listing: page => page.isListing, static: page => page.isStatic, home: page => page.isHome, - search: page => page.type === 'search', + search: page => page.id === 'search', collections: page => page.isCollectionsListing, }, }; diff --git a/src/test/blocks/schema.test.js b/src/test/blocks/schema.test.js index 75c626482..58e90a563 100644 --- a/src/test/blocks/schema.test.js +++ b/src/test/blocks/schema.test.js @@ -932,14 +932,9 @@ describe('Application/Schema', () => { describe('property: isStatic', () => { it('returns true for static pages', () => { - const page = Page.records.get('static_about'); + const page = Page.records.get('about'); expect(page.isStatic).toEqual(true); }); - - it('returns false for all other pages', () => { - const page = Page.records.get('home'); - expect(page.isStatic).toEqual(false); - }); }); describe('property: isCollectionsListing', () => { @@ -1011,7 +1006,7 @@ describe('Application/Schema', () => { }); it('returns false for the 404 page', () => { - const page = Page.records.get('static_404'); + const page = Page.records.get('404'); expect(page.isIndexable).toEqual(false); }); }); @@ -1064,7 +1059,7 @@ describe('Application/Schema', () => { }); it('returns the correct value for the search page', () => { - const page = Page.records.get('static_search'); + const page = Page.records.get('search'); const pageContext = page.context; expect(pageContext.searchIndex.length).toBe(26); }); From 47fefb7e02b9f35a754deab33972d680d80f14af Mon Sep 17 00:00:00 2001 From: Angelos Chalaris Date: Sat, 29 Apr 2023 23:19:29 +0300 Subject: [PATCH 06/44] Skip validations --- src/blocks/models/page.js | 26 -------------------------- src/blocks/models/snippet.js | 14 ++------------ 2 files changed, 2 insertions(+), 38 deletions(-) diff --git a/src/blocks/models/page.js b/src/blocks/models/page.js index 91d633284..6ca47ea14 100644 --- a/src/blocks/models/page.js +++ b/src/blocks/models/page.js @@ -220,32 +220,6 @@ export const page = { 'isListing', 'isHome', ], - validators: { - listingHasSnippets: page => { - if (!page.isListing || page.id.startsWith('listing_collections')) - return true; - return page.snippets && page.snippets.length > 0; - }, - listingHasPageNumber: page => { - if (!page.isListing) return true; - return page.pageNumber > 0; - }, - staticHasUniqueSlug: (page, pages) => { - if (!page.isStatic) return true; - if (!page.slug || page.slug.length === 0) return false; - if (pages.filter(page => page.isStatic).some(p => p.slug === page.slug)) - return false; - return true; - }, - staticHasStaticPriority: page => { - if (!page.isStatic) return true; - return ( - !Number.isNaN(page.staticPriority) && - page.staticPriority >= 0 && - page.staticPriority <= 1 - ); - }, - }, scopes: { listed: page => !page.isUnlisted && page.id !== '404', indexable: { diff --git a/src/blocks/models/snippet.js b/src/blocks/models/snippet.js index c6b8d439f..ced6046f6 100644 --- a/src/blocks/models/snippet.js +++ b/src/blocks/models/snippet.js @@ -8,18 +8,8 @@ export const snippet = { name: 'Snippet', fields: [ { name: 'fileName', type: 'stringRequired' }, - { - name: 'title', - type: 'stringRequired', - }, - { - name: 'tags', - type: 'stringArray', - validators: { - minLength: 1, - uniqueValues: true, - }, - }, + { name: 'title', type: 'stringRequired' }, + { name: 'tags', type: 'stringArray' }, { name: 'shortTitle', type: 'string' }, { name: 'dateModified', type: 'dateRequired' }, { name: 'listed', type: 'booleanRequired' }, From a5f4fcff98b411d0b92da6dc53de2facd5a231fc Mon Sep 17 00:00:00 2001 From: Angelos Chalaris Date: Sun, 30 Apr 2023 20:19:52 +0300 Subject: [PATCH 07/44] Install unist-util-select --- package-lock.json | 30 ++++++++++++++++++++++++++++++ package.json | 1 + 2 files changed, 31 insertions(+) diff --git a/package-lock.json b/package-lock.json index 6cbf23751..d961b158d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -36,6 +36,7 @@ "remark-gfm": "^1.0.0", "sass": "^1.58.3", "sharp": "^0.31.3", + "unist-util-select": "3.0.4", "unist-util-visit": "^2.0.3", "unist-util-visit-parents": "^3.1.1", "webfonts-generator": "^0.4.0" @@ -5679,6 +5680,12 @@ "url": "https://github.com/sponsors/fb55" } }, + "node_modules/css-selector-parser": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/css-selector-parser/-/css-selector-parser-1.4.1.tgz", + "integrity": "sha512-HYPSb7y/Z7BNDCOrakL4raGO2zltZkbeXyAd6Tg9obzix6QhzxCotdBl6VT0Dv4vZfJGVz3WL/xaEI9Ly3ul0g==", + "dev": true + }, "node_modules/css-tree": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-2.2.1.tgz", @@ -11545,6 +11552,12 @@ "node": ">=0.10.0" } }, + "node_modules/not": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/not/-/not-0.1.0.tgz", + "integrity": "sha512-5PDmaAsVfnWUgTUbJ3ERwn7u79Z0dYxN9ErxCpVJJqe2RK0PJ3z+iFUxuqjwtlDDegXvtWoxD/3Fzxox7tFGWA==", + "dev": true + }, "node_modules/npm-run-path": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.1.0.tgz", @@ -15739,6 +15752,23 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/unist-util-select": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/unist-util-select/-/unist-util-select-3.0.4.tgz", + "integrity": "sha512-xf1zCu4okgPqGLdhCDpRnjwBNyv3EqjiXRUbz2SdK1+qnLMB7uXXajfzuBvvbHoQ+JLyp4AEbFCGndmc6S72sw==", + "dev": true, + "dependencies": { + "css-selector-parser": "^1.0.0", + "not": "^0.1.0", + "nth-check": "^2.0.0", + "unist-util-is": "^4.0.0", + "zwitch": "^1.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/unist-util-stringify-position": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-2.0.3.tgz", diff --git a/package.json b/package.json index d7161d28a..57d5c4a75 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "remark-gfm": "^1.0.0", "sass": "^1.58.3", "sharp": "^0.31.3", + "unist-util-select": "3.0.4", "unist-util-visit": "^2.0.3", "unist-util-visit-parents": "^3.1.1", "webfonts-generator": "^0.4.0" From 05fd190db1c8bde4d0000c8e4f5af0a642d0f8ce Mon Sep 17 00:00:00 2001 From: Angelos Chalaris Date: Sun, 30 Apr 2023 20:20:29 +0300 Subject: [PATCH 08/44] Simplify snippet card rendering Expect all snippet code in body. --- src/components/SnippetCard.astro | 22 --------------- src/styles/components/_snippet-card.scss | 34 ++++++++++++++---------- 2 files changed, 20 insertions(+), 36 deletions(-) diff --git a/src/components/SnippetCard.astro b/src/components/SnippetCard.astro index 17f85098a..2c04ca9fd 100644 --- a/src/components/SnippetCard.astro +++ b/src/components/SnippetCard.astro @@ -4,8 +4,6 @@ import prefabs from 'prefabs/index.js'; import Image from 'components/Image.astro'; const { snippet } = Astro.props; - -const hasCodeBlocks = Boolean(snippet.codeBlocks && snippet.codeBlocks.length); ---
@@ -25,26 +23,6 @@ const hasCodeBlocks = Boolean(snippet.codeBlocks && snippet.codeBlocks.length); class='card-description flex flex-col' set:html={snippet.fullDescription} /> - { - hasCodeBlocks && ( -
- {snippet.codeBlocks.map(({ language, htmlContent }) => ( -
-
-            
- ))} -
- ) - } { snippet.author ? (
diff --git a/src/styles/components/_snippet-card.scss b/src/styles/components/_snippet-card.scss index 738517d0f..d39cb0687 100644 --- a/src/styles/components/_snippet-card.scss +++ b/src/styles/components/_snippet-card.scss @@ -1,18 +1,4 @@ .snippet-card { - .card-source-content { - .code-highlight:not(:last-of-type) > pre { - padding-block-end: 2rem; - } - - .code-highlight:not(:first-of-type) > pre { - margin-block-start: -0.875rem; - } - - .code-highlight:last-of-type > pre { - padding-block-end: 1.5rem; - } - } - .code-highlight { --action-display: none; @@ -48,6 +34,26 @@ &:last-child { margin-block-end: 1.5rem; } + + > pre { + padding-block-end: 1.5rem; + } + + + .code-highlight { + margin-top: 0; + + > pre { + margin-block-start: -0.5rem; + + + .action-btn { + top: 0; + } + } + } + + &:last-of-type > pre { + padding-block-end: 1.5rem; + } } } From 24f591abf737d393035d43b8e223b9164c6352e5 Mon Sep 17 00:00:00 2001 From: Angelos Chalaris Date: Sun, 30 Apr 2023 20:21:11 +0300 Subject: [PATCH 09/44] Simplify snippet extraction and parsing --- src/blocks/extractor/index.js | 128 ++++++++---------- src/blocks/extractor/markdownParser.js | 128 +++++++++--------- .../blocks/extractor/markdownParser.test.js | 48 +------ 3 files changed, 131 insertions(+), 173 deletions(-) diff --git a/src/blocks/extractor/index.js b/src/blocks/extractor/index.js index 94c1a55f2..5bb078ce9 100644 --- a/src/blocks/extractor/index.js +++ b/src/blocks/extractor/index.js @@ -1,5 +1,6 @@ /* eslint-disable no-unused-vars */ import pathSettings from 'settings/paths'; +import JSX_SNIPPET_PRESETS from 'settings/jsxSnippetPresets'; import { Logger } from 'blocks/utilities/logger'; import { Content } from 'blocks/utilities/content'; import { TextParser } from 'blocks/extractor/textParser'; @@ -226,10 +227,23 @@ export class Extractor { static extractSnippets = async (contentDir, contentConfigs, languageData) => { const logger = new Logger('Extractor.extractSnippets'); logger.log('Extracting snippets'); + + MarkdownParser.loadLanguageData(languageData); let snippets = []; + await Promise.all( contentConfigs.map(config => { + const isBlog = config.isBlog; + const isCSS = config.id === '30css'; + const isReact = config.id === '30react'; const snippetsPath = `${contentDir}/sources/${config.dirName}/snippets`; + const languageKeys = isBlog + ? [] + : isCSS + ? ['js', 'html', 'css'] + : isReact + ? ['js', 'jsx'] + : [config.language.short]; return TextParser.fromDir(snippetsPath).then(snippetData => { const parsedData = snippetData.map(snippet => { @@ -251,22 +265,11 @@ export class Extractor { fileName.slice(0, -3) )}`; const tags = rawTags.map(tag => tag.toLowerCase()); - // TODO: Temporary fix to get rid of a previous workaround - const hasOptionalLanguage = config.id === '30react'; - - const languageKeys = - config.id === '30blog' - ? [] - : config.id === '30css' - ? ['js', 'html', 'css'] - : config.id === '30react' - ? ['js', 'jsx'] - : [config.language.short]; const bodyText = body .slice(0, body.indexOf(mdCodeFence)) .replace(/\r\n/g, '\n'); - const isLongBlog = config.isBlog && bodyText.indexOf('\n\n') > 180; + const isLongBlog = isBlog && bodyText.indexOf('\n\n') > 180; const shortSliceIndex = isLongBlog ? bodyText.indexOf(' ', 160) : bodyText.indexOf('\n\n'); @@ -276,67 +279,57 @@ export class Extractor { : `${bodyText.slice(0, shortSliceIndex)}${ isLongBlog ? '...' : '' }`; - const fullText = config.isBlog ? body : bodyText; + + const fullText = body; const seoDescription = stripMarkdownFormat(shortText); - let code = { - html: null, - css: null, - js: null, - style: null, - src: null, - example: null, - }; - let rawCode = {}; - if (!config.isBlog) { - const codeBlocks = [...body.matchAll(codeMatcher)].map(v => ({ - raw: v[0].trim(), - code: v.groups.code.trim(), - })); + if (seoDescription.length > 140 && unlisted !== true) { + logger.warn(`Snippet ${id} has a long SEO description.`); + } - if (config.id === '30css') { - code.html = codeBlocks[0].code; - rawCode.html = codeBlocks[0].raw; - code.css = codeBlocks[1].code; - rawCode.css = codeBlocks[1].raw; - if (codeBlocks.length > 2) { - code.js = codeBlocks[2].code; - rawCode.js = codeBlocks[2].raw; - } - } else if (hasOptionalLanguage && codeBlocks.length > 2) { - code.style = codeBlocks[0].code; - rawCode.style = codeBlocks[0].raw; - code.src = codeBlocks[1].code; - rawCode.src = codeBlocks[1].raw; - code.example = codeBlocks[2].code; - rawCode.example = codeBlocks[2].raw; - } else { - if (codeBlocks.length === 0) console.log(id); - if (hasOptionalLanguage) { - code.style = ''; - rawCode.style = ''; - } - code.src = codeBlocks[0].code; - rawCode.src = codeBlocks[0].raw; - code.example = codeBlocks[1].code; - rawCode.example = codeBlocks[1].raw; + let code = null; + + if (isCSS || isReact) { + const codeBlocks = [...body.matchAll(codeMatcher)].map(v => + v.groups.code.trim() + ); + + if (isCSS) { + code = { + html: codeBlocks[0], + css: codeBlocks[1], + js: codeBlocks[2] || '', + }; + } + + if (isReact) { + code = + codeBlocks.length > 2 + ? { + js: `${codeBlocks[1]}\n\n${codeBlocks[2]}`, + css: codeBlocks[0], + } + : { + js: `${codeBlocks[0]}\n\n${codeBlocks[1]}`, + css: '', + }; + /* eslint-disable camelcase */ + code = { + ...code, + html: JSX_SNIPPET_PRESETS.envHtml, + js_pre_processor: JSX_SNIPPET_PRESETS.jsPreProcessor, + js_external: JSX_SNIPPET_PRESETS.jsImports.join(';'), + }; + /* eslint-enable camelcase */ } } const html = MarkdownParser.parseSegments( { - texts: { - fullDescription: fullText, - description: shortText, - }, - codeBlocks: rawCode, + fullDescription: fullText, + description: shortText, }, - { - isBlog: config.isBlog, - assetPath: `/${pathSettings.staticAssetPath}`, - languageData, - languageKeys, - } + languageKeys ); return { @@ -351,12 +344,7 @@ export class Extractor { shortText, fullText, ...html, - htmlCode: code.html, - cssCode: code.css, - jsCode: code.js, - styleCode: code.style, - srcCode: code.src, - exampleCode: code.example, + code, cover, author, seoDescription, diff --git a/src/blocks/extractor/markdownParser.js b/src/blocks/extractor/markdownParser.js index d6d9ad999..5dd0e15e3 100644 --- a/src/blocks/extractor/markdownParser.js +++ b/src/blocks/extractor/markdownParser.js @@ -4,12 +4,15 @@ import toHAST from 'mdast-util-to-hast'; import hastToHTML from 'hast-util-to-html'; import visit from 'unist-util-visit'; import visitParents from 'unist-util-visit-parents'; +import { selectAll } from 'unist-util-select'; import Prism from 'prismjs'; import getLoader from 'prismjs/dependencies'; import prismComponents from 'prismjs/components'; -import { escapeHTML, optimizeAllNodes, convertToValidId } from 'utils'; +import { escapeHTML, convertToValidId } from 'utils'; import prefabs from 'prefabs'; +import pathSettings from 'settings/paths'; +const assetPath = `/${pathSettings.staticAssetPath}`; const loader = getLoader( prismComponents, ['markup', 'javascript', 'js-extras', 'jsx', 'python', 'css', 'css-extras'], @@ -30,7 +33,7 @@ const remarkParser = new remark() .use(remarkGfm) .data('settings', remarkOptions); -const commonTransformers = [ +const textTransformers = [ // Add 'rel' and 'target' to external links { matcher: /(href="https?:\/\/)/g, @@ -82,12 +85,36 @@ const commonTransformers = [ matcher: /\s*\n*\s*\s*\n*\s*\s*\n*\s*
<\/th>/g, replacer: '', }, + // Tranform relative paths for images + { + matcher: /(

)*]*)>(<\/p>)*/g, + replacer: (match, openTag, imgSrc, imgRest) => { + const imgName = imgSrc.slice(0, imgSrc.lastIndexOf('.')); + if (imgSrc.endsWith('.png') || imgSrc.endsWith('.svg')) { + return ``; + } + return ` + + + `; + }, + }, ]; /** * Parses markdown strings, returning plain objects. */ export class MarkdownParser { + static _languageData = new Map(); + + /** + * Caches the language data for later use. + * @param {Map} languageData - A map of language names to language data. + */ + static loadLanguageData = languageData => { + MarkdownParser._languageData = languageData; + }; + /** * Get the real name of a language given it or an alias. * @param {string} name - Name or alias of a language. @@ -143,12 +170,8 @@ export class MarkdownParser { * Parses markdown into HTML from a given markdown string, using remark + prismjs. * @param {string} markdown - The markdown string to be parsed. */ - static parseMarkdown = ( - markdown, - isText = false, - languageData = [], - referenceKeys = null - ) => { + static parseMarkdown = (markdown, isText = false, referenceKeys = null) => { + const languageData = MarkdownParser._languageData; const ast = remarkParser.parse(markdown); const languageObjects = {}; @@ -162,7 +185,10 @@ export class MarkdownParser { }); // Highlight code blocks + let codeNodeIndex = -1; + let languageStringLiteralList = []; visit(ast, `code`, node => { + codeNodeIndex++; const languageName = node.lang ? node.lang : `text`; node.type = `html`; @@ -179,6 +205,7 @@ export class MarkdownParser { if (languageObject && !languageObjects[languageName]) languageObjects[languageName] = languageObject; + // TODO: Add notranslate and translate=no to the inner pre node.value = isText ? [ `

`, @@ -188,6 +215,25 @@ export class MarkdownParser { `
`, ].join('') : `${highlightedCode}`; + + node.cni = codeNodeIndex; + node.lsl = languageStringLiteral; + languageStringLiteralList.push(languageStringLiteral); + }); + + // Revisit code blocks, find the last if necessary and change the language + // to 'Examples'. Should only match 2 consecutive code blocks of the same + // language and only once for the very last block on the page. + selectAll('html + html', ast).forEach(node => { + if ( + node.cni === codeNodeIndex && + languageStringLiteralList.slice(-2)[0] === node.lsl + ) { + node.value = node.value.replace( + `data-code-language="${node.lsl}"`, + 'data-code-language="Examples"' + ); + } }); const references = new Map( @@ -229,59 +275,19 @@ export class MarkdownParser { return hastToHTML(htmlAst, { allowDangerousHtml: true }); }; - static parseSegments = ( - { texts, codeBlocks }, - { isBlog, assetPath, languageData, languageKeys } - ) => { - const result = {}; - Object.entries(texts).forEach(([key, value]) => { - if (!value) return; - - const referenceKeys = isBlog - ? null - : languageKeys.length - ? languageKeys - : []; - result[`${key}Html`] = value.trim() - ? MarkdownParser.parseMarkdown(value, true, languageData, referenceKeys) - : ''; - }); - result.descriptionHtml = commonTransformers.reduce( - (acc, { matcher, replacer }) => { - return acc.replace(matcher, replacer); - }, - result.descriptionHtml - ); - result.fullDescriptionHtml = commonTransformers.reduce( - (acc, { matcher, replacer }) => { - return acc.replace(matcher, replacer); - }, - result.fullDescriptionHtml - ); + static parseSegments = (texts, languageKeys) => + Object.entries(texts).reduce((result, [key, value]) => { + if (!value.trim()) return result; - if (isBlog) { - // Transform relative paths for images - result.fullDescriptionHtml = result.fullDescriptionHtml.replace( - /(

)*]*)>(<\/p>)*/g, - (match, openTag, imgSrc, imgRest) => { - const imgName = imgSrc.slice(0, imgSrc.lastIndexOf('.')); - if (imgSrc.endsWith('.png') || imgSrc.endsWith('.svg')) { - return ``; - } - return ` - - - `; - } + const htmlKey = `${key}Html`; + result[htmlKey] = MarkdownParser.parseMarkdown(value, true, languageKeys); + result[htmlKey] = textTransformers.reduce( + (acc, { matcher, replacer }) => { + return acc.replace(matcher, replacer); + }, + result[htmlKey] ); - } else { - Object.entries(codeBlocks).forEach(([key, value]) => { - if (!value) return; - result[`${key}CodeBlockHtml`] = value.trim() - ? optimizeAllNodes(MarkdownParser.parseMarkdown(value)).trim() - : ''; - }); - } - return result; - }; + + return result; + }, {}); } diff --git a/src/test/blocks/extractor/markdownParser.test.js b/src/test/blocks/extractor/markdownParser.test.js index 4f2de92a3..b496eeab0 100644 --- a/src/test/blocks/extractor/markdownParser.test.js +++ b/src/test/blocks/extractor/markdownParser.test.js @@ -1,57 +1,21 @@ import { MarkdownParser } from 'blocks/extractor/markdownParser'; describe('MarkdownParser', () => { - let snippetResult, blogResult; + let snippetResult; + beforeAll(() => { snippetResult = MarkdownParser.parseSegments( { - texts: { - fullDescription: 'This is a `snippet` *description*.', - description: 'This is...', - }, - codeBlocks: { - src: '```js\nHello\n```', - example: '```js\nHi\n```', - }, + fullDescription: 'This is a `snippet` *description*.', + description: 'This is...', }, - { - isBlog: false, - assetPath: '/assets', - languageData: [], - languageKeys: [], - } - ); - blogResult = MarkdownParser.parseSegments( - { - texts: { - fullDescription: 'This is a **blog**.\n\n* Hi\n* Hello\n', - description: 'This is...', - }, - codeBlocks: {}, - }, - { - isBlog: true, - assetPath: '/assets', - languageData: [], - languageKeys: [], - } + [] ); }); describe('parseSegments', () => { - it('returns the correct results for normal snippets', () => { + it('returns the correct results for any snippet', () => { expect(Object.keys(snippetResult).sort()).toEqual( - [ - 'descriptionHtml', - 'exampleCodeBlockHtml', - 'fullDescriptionHtml', - 'srcCodeBlockHtml', - ].sort() - ); - }); - - it('returns the correct results for blog snippets', () => { - expect(Object.keys(blogResult).sort()).toEqual( ['descriptionHtml', 'fullDescriptionHtml'].sort() ); }); From ca14522bcd58ad4406410a7e63782e4e91eb9428 Mon Sep 17 00:00:00 2001 From: Angelos Chalaris Date: Sun, 30 Apr 2023 20:21:21 +0300 Subject: [PATCH 10/44] Remove unused utilities --- src/test/utils.test.js | 24 ----------------------- src/utils/index.js | 4 ---- src/utils/string.js | 44 ------------------------------------------ 3 files changed, 72 deletions(-) diff --git a/src/test/utils.test.js b/src/test/utils.test.js index 1774aeadd..c4bca54bb 100644 --- a/src/test/utils.test.js +++ b/src/test/utils.test.js @@ -2,8 +2,6 @@ import { uniqueElements, chunk, capitalize, - optimizeNodes, - optimizeAllNodes, getURLParameters, escapeHTML, stripMarkdownFormat, @@ -40,28 +38,6 @@ describe('capitalize', () => { }); }); -describe('optimizeNodes', () => { - it('optimizes nodes', () => { - const data = - 'foobar'; - const regexp = /([^\0<]*?)<\/span>([\n\r\s]*)([^\0]*?)<\/span>/gm; - const replacer = (match, p1, p2, p3) => - `${p1}${p2}${p3}`; - const result = 'foobar'; - expect(optimizeNodes(data, regexp, replacer)).toBe(result); - }); -}); - -describe('optimizeAllNodes', () => { - it('optimizes all nodes', () => { - const data = - 'foobar foobar foobar'; - const result = - 'foobar foobar foobar'; - expect(optimizeAllNodes(data)).toBe(result); - }); -}); - describe('getURLParameters', () => { it('returns an object containing the parameters of the current URL', () => { expect( diff --git a/src/utils/index.js b/src/utils/index.js index 4a1859118..76ed3e2fa 100644 --- a/src/utils/index.js +++ b/src/utils/index.js @@ -6,8 +6,6 @@ import { escapeHTML, stripHTMLTags, stripMarkdownFormat, - optimizeNodes, - optimizeAllNodes, getURLParameters, } from './string'; @@ -23,8 +21,6 @@ export { escapeHTML, stripHTMLTags, stripMarkdownFormat, - optimizeNodes, - optimizeAllNodes, getURLParameters, throttle, uniqueElements, diff --git a/src/utils/string.js b/src/utils/string.js index df0524197..f838f825a 100644 --- a/src/utils/string.js +++ b/src/utils/string.js @@ -7,50 +7,6 @@ export const capitalize = ([first, ...rest], lowerRest = false) => first.toUpperCase() + (lowerRest ? rest.join('').toLowerCase() : rest.join('')); -/** - * Optimizes nodes in an HTML string. - * @param {string} data - The HTML string to be optimized. - * @param {RegExp} regexp - The matcher for the optimization. - * @param {string} replacer - The replacement for the matches. - */ -export const optimizeNodes = (data, regexp, replacer) => { - let count = 0; - let output = data; - do { - output = output.replace(regexp, replacer); - count = 0; - while (regexp.exec(output) !== null) ++count; - } while (count > 0); - return output; -}; - -/** Optimizes all nodes in an HTML string. - * @param {string} html - The HTML string to be optimized. - */ -export const optimizeAllNodes = html => { - let output = html; - // Optimize punctuation nodes - output = optimizeNodes( - output, - /([^\0<]*?)<\/span>([\n\r\s]*)([^\0]*?)<\/span>/gm, - (match, p1, p2, p3) => - `${p1}${p2}${p3}` - ); - // Optimize operator nodes - output = optimizeNodes( - output, - /([^\0<]*?)<\/span>([\n\r\s]*)([^\0]*?)<\/span>/gm, - (match, p1, p2, p3) => `${p1}${p2}${p3}` - ); - // Optimize keyword nodes - output = optimizeNodes( - output, - /([^\0<]*?)<\/span>([\n\r\s]*)([^\0]*?)<\/span>/gm, - (match, p1, p2, p3) => `${p1}${p2}${p3}` - ); - return output; -}; - /** * Returns an object containing the parameters of the current URL. * @param {string} url - The URL to be parsed. From fba20da377d48d6dceeeb1e16d8132303efa21b6 Mon Sep 17 00:00:00 2001 From: Angelos Chalaris Date: Sun, 30 Apr 2023 20:21:33 +0300 Subject: [PATCH 11/44] Simplify Snippet model --- src/blocks/models/snippet.js | 61 +------------------ .../serializers/snippetContextSerializer.js | 23 +------ 2 files changed, 2 insertions(+), 82 deletions(-) diff --git a/src/blocks/models/snippet.js b/src/blocks/models/snippet.js index ced6046f6..e95774acb 100644 --- a/src/blocks/models/snippet.js +++ b/src/blocks/models/snippet.js @@ -18,18 +18,7 @@ export const snippet = { { name: 'fullText', type: 'stringRequired' }, { name: 'descriptionHtml', type: 'string' }, { name: 'fullDescriptionHtml', type: 'string' }, - { name: 'htmlCodeBlockHtml', type: 'string' }, - { name: 'cssCodeBlockHtml', type: 'string' }, - { name: 'jsCodeBlockHtml', type: 'string' }, - { name: 'styleCodeBlockHtml', type: 'string' }, - { name: 'srcCodeBlockHtml', type: 'string' }, - { name: 'exampleCodeBlockHtml', type: 'string' }, - { name: 'htmlCode', type: 'string' }, - { name: 'cssCode', type: 'string' }, - { name: 'jsCode', type: 'string' }, - { name: 'styleCode', type: 'string' }, - { name: 'srcCode', type: 'string' }, - { name: 'exampleCode', type: 'string' }, + { name: 'code', type: 'object' }, { name: 'cover', type: 'stringRequired' }, { name: 'seoDescription', type: 'stringRequired' }, ], @@ -160,49 +149,6 @@ export const snippet = { return [homeCrumb, languageCrumb, tagCrumb, snippetCrumb].filter(Boolean); }, - codeBlocks: snippet => { - if (snippet.isBlog) return []; - if (snippet.isCSS) { - let blocks = [ - { - language: { short: 'html', long: 'HTML' }, - htmlContent: snippet.htmlCodeBlockHtml, - }, - { - language: { short: 'css', long: 'CSS' }, - htmlContent: snippet.cssCodeBlockHtml, - }, - ]; - if (snippet.jsCodeBlockHtml) - blocks.push({ - language: { short: 'js', long: 'JavaScript' }, - htmlContent: snippet.jsCodeBlockHtml, - }); - return blocks; - } - let blocks = [ - { - language: { - short: snippet.language.short, - long: snippet.language.name, - }, - htmlContent: snippet.srcCodeBlockHtml, - }, - { - language: { - short: snippet.language.short, - long: 'Examples', - }, - htmlContent: snippet.exampleCodeBlockHtml, - }, - ]; - if (snippet.styleCodeBlockHtml) - blocks.splice(1, 0, { - language: { short: 'css', long: 'CSS' }, - htmlContent: snippet.styleCodeBlockHtml, - }); - return blocks; - }, hasCollection: snippet => Boolean(snippet.collections && snippet.collections.length), recommendedCollection: snippet => @@ -215,11 +161,6 @@ export const snippet = { ...snippet.tags, (snippet.language && snippet.language.long) || '', snippet.type || '', - snippet.srcCode || '', - snippet.cssCode || '', - snippet.htmlCode || '', - snippet.jsCode || '', - snippet.styleCode || '', snippet.fullText || '', snippet.shortText || '', ] diff --git a/src/blocks/serializers/snippetContextSerializer.js b/src/blocks/serializers/snippetContextSerializer.js index 80401532a..0883af816 100644 --- a/src/blocks/serializers/snippetContextSerializer.js +++ b/src/blocks/serializers/snippetContextSerializer.js @@ -1,29 +1,9 @@ import pathSettings from 'settings/paths'; -import JSX_SNIPPET_PRESETS from 'settings/jsxSnippetPresets'; export const snippetContextSerializer = { name: 'SnippetContextSerializer', methods: { - code: snippet => { - if (snippet.isCSS) - return { - html: snippet.htmlCode, - css: snippet.cssCode, - js: snippet.jsCode, - }; - if (snippet.isReact) { - /* eslint-disable camelcase */ - return { - js: `${snippet.srcCode}\n\n${snippet.exampleCode}`, - css: snippet.styleCode || '', - html: JSX_SNIPPET_PRESETS.envHtml, - js_pre_processor: JSX_SNIPPET_PRESETS.jsPreProcessor, - js_external: JSX_SNIPPET_PRESETS.jsImports.join(';'), - }; - /* eslint-enable camelcase */ - } - return undefined; - }, + code: snippet => snippet.code || undefined, author: snippet => snippet.author ? { @@ -48,7 +28,6 @@ export const snippetContextSerializer = { attributes: [ 'title', 'fullDescription', - 'codeBlocks', 'url', 'slug', ['dateFormatted', 'date'], From 4c99a8c07b03269f647afe4ee5d349a6e75a39ba Mon Sep 17 00:00:00 2001 From: Angelos Chalaris Date: Sun, 30 Apr 2023 20:22:12 +0300 Subject: [PATCH 12/44] Make language to repo relationship one to one --- src/blocks/models/language.js | 12 +++--------- src/blocks/schema.js | 4 ++-- 2 files changed, 5 insertions(+), 11 deletions(-) diff --git a/src/blocks/models/language.js b/src/blocks/models/language.js index 3deb036f3..8630e4086 100644 --- a/src/blocks/models/language.js +++ b/src/blocks/models/language.js @@ -6,18 +6,12 @@ export const language = { { name: 'name', type: 'stringRequired' }, ], properties: { - mainRepository: language => - language.repositories && language.repositories.length - ? language.repositories.first - : null, slugPrefix: language => - language.mainRepository ? `/${language.mainRepository.slug}` : null, + language.repository ? `/${language.repository.slug}` : null, tagShortIds: language => - language.mainRepository - ? language.mainRepository.tags.flatPluck('shortId') - : [], + language.repository ? language.repository.tags.flatPluck('shortId') : [], }, - cacheProperties: ['mainRepository', 'slugPrefix', 'tagShortIds'], + cacheProperties: ['slugPrefix', 'tagShortIds'], scopes: { // Hacky way to exclude the HTML language from the list full: language => language.id !== 'html', diff --git a/src/blocks/schema.js b/src/blocks/schema.js index 8ea40cd91..85f007701 100644 --- a/src/blocks/schema.js +++ b/src/blocks/schema.js @@ -13,8 +13,8 @@ export const schema = { }, { from: { model: 'Repository', name: 'language' }, - to: { model: 'Language', name: 'repositories' }, - type: 'manyToOne', + to: { model: 'Language', name: 'repository' }, + type: 'oneToOne', }, { from: { model: 'Tag', name: 'repository' }, From 50013678834965b3f6eb3fcb6404fe13dd0f8f53 Mon Sep 17 00:00:00 2001 From: Angelos Chalaris Date: Sun, 30 Apr 2023 23:27:29 +0300 Subject: [PATCH 13/44] Fix tag names --- src/blocks/extractor/index.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/blocks/extractor/index.js b/src/blocks/extractor/index.js index 5bb078ce9..2ac4e96d1 100644 --- a/src/blocks/extractor/index.js +++ b/src/blocks/extractor/index.js @@ -185,7 +185,7 @@ export class Extractor { ? tagMetadata.name : isBlog ? literals.blogTag(tag) - : literals.codelangTag(language.long, tag); + : literals.codelangTag(language.name, tag); const shortName = tagMetadata && tagMetadata.shortName ? tagMetadata.shortName @@ -193,7 +193,7 @@ export class Extractor { ? tagMetadata.name : isBlog ? literals.shortBlogTag(tag) - : literals.shortCodelangTag(language.long, tag); + : literals.shortCodelangTag(language.name, tag); const description = tagMetadata && tagMetadata.description ? tagMetadata.description From 71663ac2664860e7ca26b80c9752073434e1e0df Mon Sep 17 00:00:00 2001 From: Angelos Chalaris Date: Sun, 30 Apr 2023 23:28:06 +0300 Subject: [PATCH 14/44] Cache dateFormatted in snippet --- src/blocks/models/snippet.js | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/blocks/models/snippet.js b/src/blocks/models/snippet.js index e95774acb..a429f554b 100644 --- a/src/blocks/models/snippet.js +++ b/src/blocks/models/snippet.js @@ -65,6 +65,12 @@ export const snippet = { isListed: snippet => snippet.repository.featured && snippet.listed && !snippet.isScheduled, ranking: snippet => Ranker.rankIndexableContent(snippet.indexableContent), + dateFormatted: snippet => + snippet.dateModified.toLocaleDateString('en-US', { + day: 'numeric', + month: 'short', + year: 'numeric', + }), searchTokensArray: snippet => { const tokenizableElements = snippet.isBlog ? [ @@ -196,6 +202,7 @@ export const snippet = { 'isListed', 'isScheduled', 'isPublished', + 'dateFormatted', 'searchTokensArray', 'searchTokens', 'language', From 9d402d308b2e450a066e604b8dc134ef1ca71c68 Mon Sep 17 00:00:00 2001 From: Angelos Chalaris Date: Sun, 30 Apr 2023 23:28:29 +0300 Subject: [PATCH 15/44] Tidy up snippet serializers --- src/blocks/serializers/snippetContextSerializer.js | 11 +---------- src/blocks/serializers/snippetPreviewSerializer.js | 10 ++-------- src/pages/[lang]/s/[snippet].astro | 2 +- 3 files changed, 4 insertions(+), 19 deletions(-) diff --git a/src/blocks/serializers/snippetContextSerializer.js b/src/blocks/serializers/snippetContextSerializer.js index 0883af816..a2832f552 100644 --- a/src/blocks/serializers/snippetContextSerializer.js +++ b/src/blocks/serializers/snippetContextSerializer.js @@ -12,17 +12,8 @@ export const snippetContextSerializer = { github: snippet.author.github, } : undefined, - type: snippet => (snippet.isBlog ? snippet.type : undefined), coverUrl: snippet => - snippet.cover - ? `/${pathSettings.staticAssetPath}/cover/${snippet.cover}.jpg` - : undefined, - dateFormatted: snippet => - snippet.dateModified.toLocaleDateString('en-US', { - day: 'numeric', - month: 'short', - year: 'numeric', - }), + `/${pathSettings.staticAssetPath}/cover/${snippet.cover}.jpg`, fullDescription: snippet => snippet.fullDescriptionHtml, }, attributes: [ diff --git a/src/blocks/serializers/snippetPreviewSerializer.js b/src/blocks/serializers/snippetPreviewSerializer.js index dee71869b..772dee51a 100644 --- a/src/blocks/serializers/snippetPreviewSerializer.js +++ b/src/blocks/serializers/snippetPreviewSerializer.js @@ -17,17 +17,11 @@ export const snippetPreviewSerializer = { withSearch ? snippet.formattedMiniPreviewTag : undefined, previewUrl: (snippet, { withSearch } = {}) => { if (withSearch) return undefined; - return snippet.cover - ? `/${pathSettings.staticAssetPath}/preview/${snippet.cover}.jpg` - : undefined; + return `/${pathSettings.staticAssetPath}/preview/${snippet.cover}.jpg`; }, dateFormatted: (snippet, { withSearch } = {}) => { if (withSearch) return undefined; - return snippet.dateModified.toLocaleDateString('en-US', { - day: 'numeric', - month: 'short', - year: 'numeric', - }); + return snippet.dateFormatted; }, type: () => 'snippet', }, diff --git a/src/pages/[lang]/s/[snippet].astro b/src/pages/[lang]/s/[snippet].astro index 284d97d0a..f8a9f6f55 100644 --- a/src/pages/[lang]/s/[snippet].astro +++ b/src/pages/[lang]/s/[snippet].astro @@ -35,7 +35,7 @@ const { Date: Mon, 1 May 2023 00:03:34 +0300 Subject: [PATCH 16/44] Replace serializers with behavior-specific ones Prefer serializers per behavior instead of per model. --- src/blocks/models/listing.js | 5 ++ src/blocks/models/page.js | 54 +++++++++---------- .../serializers/listingPreviewSerializer.js | 44 --------------- src/blocks/serializers/previewSerializer.js | 36 +++++++++++++ .../serializers/searchResultSerializer.js | 13 +++++ .../serializers/snippetPreviewSerializer.js | 40 -------------- src/components/Omnisearch.js | 8 ++- 7 files changed, 82 insertions(+), 118 deletions(-) delete mode 100644 src/blocks/serializers/listingPreviewSerializer.js create mode 100644 src/blocks/serializers/previewSerializer.js create mode 100644 src/blocks/serializers/searchResultSerializer.js delete mode 100644 src/blocks/serializers/snippetPreviewSerializer.js diff --git a/src/blocks/models/listing.js b/src/blocks/models/listing.js index a19921a16..562ad4d4b 100644 --- a/src/blocks/models/listing.js +++ b/src/blocks/models/listing.js @@ -78,6 +78,7 @@ export const listing = { : listing.data.shortDescription; return shortDescription ? `

${shortDescription}

` : null; }, + slug: listing => `${listing.slugPrefix}/p/1`, splash: listing => (listing.data ? listing.data.splash : null), seoDescription: listing => literals.pageDescription(listing.type, { @@ -132,6 +133,8 @@ export const listing = { // Catch all, also catches 'custom' for collection types return listing.snippets.published; }, + formattedSnippetCount: listing => + `${listing.listedSnippets.length} snippets`, indexableContent: listing => [listing.name, listing.description].join(' ').toLowerCase(), }, @@ -274,6 +277,7 @@ export const listing = { 'shortName', 'description', 'shortDescription', + 'slug', 'splash', 'seoDescription', 'featured', @@ -281,6 +285,7 @@ export const listing = { 'isSearchable', 'searchTokens', 'listedSnippets', + 'formattedSnippetCount', ], scopes: { main: listing => listing.isMain, diff --git a/src/blocks/models/page.js b/src/blocks/models/page.js index 6ca47ea14..4daa28c01 100644 --- a/src/blocks/models/page.js +++ b/src/blocks/models/page.js @@ -58,9 +58,9 @@ export const page = { models: { Snippet, Listing }, serializers: { SnippetContextSerializer, - SnippetPreviewSerializer, ListingContextSerializer, - ListingPreviewSerializer, + SearchResultSerializer, + PreviewSerializer, }, }) => page => { @@ -77,17 +77,19 @@ export const page = { .toArray() .slice(0, TOP_SNIPPET_CARDS * 5) ).slice(0, TOP_SNIPPET_CARDS); - context.featuredCollections = - ListingPreviewSerializer.serializeArray(listedCollections); + context.featuredCollections = PreviewSerializer.serializeArray( + listedCollections, + { type: 'collection' } + ); context.featuredCollections.push({ title: 'More collections', url: '/collections/p/1', selected: false, }); - context.featuredSnippets = SnippetPreviewSerializer.serializeArray([ - ...newBlogs, - ...topSnippets, - ]); + context.featuredSnippets = PreviewSerializer.serializeArray( + [...newBlogs, ...topSnippets], + { type: 'snippet' } + ); // TODO: Move this to a better place context.splashImage = '/assets/splash/work-sunrise.png'; context.snippetListUrl = '/list/p/1'; @@ -109,14 +111,15 @@ export const page = { author: page.data.author, }); - let recommendedItems = SnippetPreviewSerializer.serializeArray( - page.data.recommendedSnippets.toArray() + let recommendedItems = PreviewSerializer.serializeArray( + page.data.recommendedSnippets.toArray(), + { type: 'snippet' } ); if (page.data.recommendedCollection) recommendedItems.unshift( - ListingPreviewSerializer.serialize( + PreviewSerializer.serialize( page.data.recommendedCollection.listing, - { withDescription: true } + { type: 'collection' } ) ); context.recommendations = recommendedItems; @@ -164,15 +167,14 @@ export const page = { } ); if (page.isCollectionsListing) { - context.snippetList = ListingPreviewSerializer.serializeArray( + context.snippetList = PreviewSerializer.serializeArray( page.listings.toArray(), - { - withDescription: true, - } + { type: 'collection' } ); } else { - context.snippetList = SnippetPreviewSerializer.serializeArray( - page.snippets.toArray() + context.snippetList = PreviewSerializer.serializeArray( + page.snippets.toArray(), + { type: 'snippet' } ); } context.structuredData = Schemer.generateListingData({ @@ -187,19 +189,13 @@ export const page = { if (page.type === 'search') { const sortedSnippets = Snippet.records.listedByPopularity; context.searchIndex = [ - ...ListingPreviewSerializer.serializeArray( + ...SearchResultSerializer.serializeArray( Listing.records.searchable.toArray(), - { - withDescription: true, - withSearch: true, - } - ), - ...SnippetPreviewSerializer.serializeArray( - sortedSnippets.toArray(), - { - withSearch: true, - } + { type: 'collection' } ), + ...SearchResultSerializer.serializeArray(sortedSnippets.toArray(), { + type: 'snippet', + }), ]; } return context; diff --git a/src/blocks/serializers/listingPreviewSerializer.js b/src/blocks/serializers/listingPreviewSerializer.js deleted file mode 100644 index 297712db5..000000000 --- a/src/blocks/serializers/listingPreviewSerializer.js +++ /dev/null @@ -1,44 +0,0 @@ -import pathSettings from 'settings/paths'; - -export const listingPreviewSerializer = { - name: 'ListingPreviewSerializer', - methods: { - description: (listing, { withDescription, withSearch } = {}) => { - if (withSearch) return undefined; - return withDescription - ? listing.shortDescription - .replace('

', '') - .replace('

', '') - .replace(/(.*?)<\/a>/g, '$1') - : undefined; - }, - tags: () => 'Collection', - searchTokens: (listing, { withSearch } = {}) => - withSearch ? listing.searchTokens : undefined, - searchResultTag: (listing, { withSearch } = {}) => - withSearch ? `${listing.listedSnippets.length} snippets` : undefined, - splashUrl: (listing, { withSearch } = {}) => { - if (withSearch) return undefined; - return listing.splash - ? `/${pathSettings.staticAssetPath}/splash/${listing.splash}` - : `/${pathSettings.staticAssetPath}/splash/laptop-view.png`; - }, - snippetCount: (listing, { withSearch } = {}) => { - if (withSearch) return undefined; - return `${listing.listedSnippets.length} snippets`; - }, - url: listing => `${listing.slugPrefix}/p/1`, - type: () => 'collection', - }, - attributes: [ - ['shortName', 'title'], - 'url', - 'description', - 'tags', - 'searchTokens', - 'searchResultTag', - ['snippetCount', 'extraContext'], - 'type', - ['splashUrl', 'cover'], - ], -}; diff --git a/src/blocks/serializers/previewSerializer.js b/src/blocks/serializers/previewSerializer.js new file mode 100644 index 000000000..479dddf86 --- /dev/null +++ b/src/blocks/serializers/previewSerializer.js @@ -0,0 +1,36 @@ +import pathSettings from 'settings/paths'; + +export const previewSerializer = { + name: 'PreviewSerializer', + methods: { + title: (item, { type }) => + type === 'snippet' ? item.title : item.shortName, + description: (item, { type }) => { + const description = + type === 'snippet' ? item.descriptionHtml : item.shortDescription; + return description + .replace('

', '') + .replace('

', '') + .replace(/(.*?)<\/a>/g, '$1'); + }, + cover: (item, { type }) => { + if (type === 'snippet') + return `/${pathSettings.staticAssetPath}/preview/${item.cover}.jpg`; + return item.splash + ? `/${pathSettings.staticAssetPath}/splash/${item.splash}` + : `/${pathSettings.staticAssetPath}/splash/laptop-view.png`; + }, + tags: (item, { type }) => + type === 'snippet' ? item.formattedPreviewTags : 'Collection', + extraContext: (item, { type }) => + type === 'snippet' ? item.dateFormatted : item.formattedSnippetCount, + }, + attributes: [ + 'title', + 'description', + ['slug', 'url'], + 'cover', + 'tags', + 'extraContext', + ], +}; diff --git a/src/blocks/serializers/searchResultSerializer.js b/src/blocks/serializers/searchResultSerializer.js new file mode 100644 index 000000000..13f20fca2 --- /dev/null +++ b/src/blocks/serializers/searchResultSerializer.js @@ -0,0 +1,13 @@ +export const searchResultSerializer = { + name: 'SearchResultSerializer', + methods: { + title: (item, { type }) => + type === 'snippet' ? item.shortTitle : item.shortName, + tag: (item, { type }) => + type === 'snippet' + ? item.formattedMiniPreviewTag + : item.formattedSnippetCount, + type: (item, { type }) => type, + }, + attributes: ['title', ['slug', 'url'], 'tag', 'searchTokens', 'type'], +}; diff --git a/src/blocks/serializers/snippetPreviewSerializer.js b/src/blocks/serializers/snippetPreviewSerializer.js deleted file mode 100644 index 772dee51a..000000000 --- a/src/blocks/serializers/snippetPreviewSerializer.js +++ /dev/null @@ -1,40 +0,0 @@ -import pathSettings from 'settings/paths'; - -export const snippetPreviewSerializer = { - name: 'SnippetPreviewSerializer', - methods: { - description: (snippet, { withSearch } = {}) => { - if (withSearch) return undefined; - return snippet.descriptionHtml - .trim() - .replace('

', '') - .replace('

', '') - .replace(/(.*?)<\/a>/g, '$1'); - }, - searchTokens: (snippet, { withSearch } = {}) => - withSearch ? snippet.searchTokens : undefined, - searchResultTag: (snippet, { withSearch } = {}) => - withSearch ? snippet.formattedMiniPreviewTag : undefined, - previewUrl: (snippet, { withSearch } = {}) => { - if (withSearch) return undefined; - return `/${pathSettings.staticAssetPath}/preview/${snippet.cover}.jpg`; - }, - dateFormatted: (snippet, { withSearch } = {}) => { - if (withSearch) return undefined; - return snippet.dateFormatted; - }, - type: () => 'snippet', - }, - attributes: [ - 'title', - 'shortTitle', - ['slug', 'url'], - 'description', - ['formattedPreviewTags', 'tags'], - ['dateFormatted', 'extraContext'], - 'searchTokens', - 'searchResultTag', - 'type', - ['previewUrl', 'cover'], - ], -}; diff --git a/src/components/Omnisearch.js b/src/components/Omnisearch.js index a513d1a76..8677e9ca4 100644 --- a/src/components/Omnisearch.js +++ b/src/components/Omnisearch.js @@ -112,12 +112,10 @@ class Omnisearch extends HTMLElement { createResultHTML(result) { return `
  • -

    - ${result.shortTitle || result.title} +

    + ${result.title}

    - ${result.searchResultTag} + ${result.tag}

  • `; } From 6a5af6df86d5b0f2a15136cf6b68b45f3d15acfc Mon Sep 17 00:00:00 2001 From: Angelos Chalaris Date: Mon, 1 May 2023 14:16:04 +0300 Subject: [PATCH 17/44] Remove listing type from featuredListings --- src/blocks/application.js | 7 ++++--- src/blocks/models/listing.js | 1 - 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/blocks/application.js b/src/blocks/application.js index 72e943f22..20531b9af 100644 --- a/src/blocks/application.js +++ b/src/blocks/application.js @@ -437,10 +437,11 @@ export class Application { relatedRecordId: repo.id, type, slugPrefix, - featuredIndex: featuredListings.indexOf(repoListingId), + featuredIndex: featuredListings.indexOf(repo.slug), }); // Populate tag listings from repositories repo.tags.forEach(tag => { + // This comes out of the extractor with a leading slash const tagSlugPrefix = tag.slugPrefix; const tagId = `tag${tagSlugPrefix}`; Listing.createRecord({ @@ -448,7 +449,7 @@ export class Application { relatedRecordId: `${tag.id}`, type: 'tag', slugPrefix: tagSlugPrefix, - featuredIndex: featuredListings.indexOf(tagId), + featuredIndex: featuredListings.indexOf(tagSlugPrefix.slice(1)), parent: repoListingId, }); }); @@ -469,7 +470,7 @@ export class Application { relatedRecordId: collection.id, type: 'collection', slugPrefix, - featuredIndex: featuredListings.indexOf(listingId), + featuredIndex: featuredListings.indexOf(collection.slug), parent, }); }); diff --git a/src/blocks/models/listing.js b/src/blocks/models/listing.js index 562ad4d4b..0b9858fce 100644 --- a/src/blocks/models/listing.js +++ b/src/blocks/models/listing.js @@ -153,7 +153,6 @@ export const listing = { name: listing.dataName, splash: listing.dataSplash, description: listing.dataDescription, - featuredListings: listing.dataFeaturedListings, }; if (listing.isBlog) return Repository.records.blog.first; if (listing.isLanguage) From 09afa8fc05d03ff1bd59ddcf3d7cd7aa3244b98c Mon Sep 17 00:00:00 2001 From: Angelos Chalaris Date: Mon, 1 May 2023 21:59:04 +0300 Subject: [PATCH 18/44] WIP --- src/blocks/application.js | 76 ++++----- src/blocks/extractor/index.js | 87 ++-------- src/blocks/models/collection.js | 15 +- src/blocks/models/language.js | 4 +- src/blocks/models/listing.js | 151 +++++------------- src/blocks/models/page.js | 1 + src/blocks/models/snippet.js | 92 ++++------- src/blocks/models/tag.js | 22 --- src/blocks/schema.js | 5 - .../serializers/searchResultSerializer.js | 6 +- 10 files changed, 138 insertions(+), 321 deletions(-) delete mode 100644 src/blocks/models/tag.js diff --git a/src/blocks/application.js b/src/blocks/application.js index 20531b9af..3c10d6bf5 100644 --- a/src/blocks/application.js +++ b/src/blocks/application.js @@ -393,21 +393,12 @@ export class Application { static populateDataset() { const logger = new Logger('Application.populateDataset'); logger.log('Populating dataset...'); - const { - Snippet, - Repository, - Collection, - Listing, - Language, - Tag, - Author, - Page, - } = Application.models; + const { Snippet, Repository, Collection, Listing, Language, Author, Page } = + Application.models; const { snippets, repositories, collections, - tags, languages, authors, mainListingConfig, @@ -415,10 +406,9 @@ export class Application { } = Application.datasetObject; const { dataFeaturedListings: featuredListings } = collectionListingConfig; - // Populate repos, languages, tags, authors, snippets + // Populate repos, languages, authors, snippets repositories.forEach(repo => Repository.createRecord(repo)); languages.forEach(language => Language.createRecord(language)); - tags.forEach(tag => Tag.createRecord(tag)); authors.forEach(author => Author.createRecord(author)); snippets.forEach(snippet => { const { dateModified, ...rest } = snippet; @@ -427,48 +417,46 @@ export class Application { dateModified: new Date(dateModified), }); }); - // Populate listings and create relationships - Repository.records.forEach(repo => { - const type = repo.isBlog ? 'blog' : 'language'; - const slugPrefix = `/${repo.slug}`; - const repoListingId = `${type}${slugPrefix}`; - Listing.createRecord({ - id: repoListingId, - relatedRecordId: repo.id, - type, - slugPrefix, - featuredIndex: featuredListings.indexOf(repo.slug), - }); - // Populate tag listings from repositories - repo.tags.forEach(tag => { - // This comes out of the extractor with a leading slash - const tagSlugPrefix = tag.slugPrefix; - const tagId = `tag${tagSlugPrefix}`; - Listing.createRecord({ - id: tagId, - relatedRecordId: `${tag.id}`, - type: 'tag', - slugPrefix: tagSlugPrefix, - featuredIndex: featuredListings.indexOf(tagSlugPrefix.slice(1)), - parent: repoListingId, - }); - }); - }); + // Populate collections, collection listings and link to snippets and parent listings collections.forEach(collection => { - const { snippetIds, typeMatcher, parent, ...rest } = collection; + const { + snippetIds, + typeMatcher, + languageMatcher, + tagMatcher, + parent, + ...rest + } = collection; const collectionRec = Collection.createRecord(rest); if (snippetIds && snippetIds.length) collectionRec.snippets = snippetIds; - if (typeMatcher) - collectionRec.snippets = Snippet.records.listedByPopularity - .where(snippet => snippet.type === typeMatcher) + else { + const queryMatchers = []; + let queryScope = 'listedByPopularity'; + if (typeMatcher) + if (typeMatcher === 'article') { + queryScope = 'listedByNew'; + queryMatchers.push(snippet => snippet.type !== 'snippet'); + } else queryMatchers.push(snippet => snippet.type === typeMatcher); + if (languageMatcher) + queryMatchers.push( + snippet => + snippet.language && snippet.language.id === languageMatcher + ); + if (tagMatcher) + queryMatchers.push(snippet => snippet.tags.includes(tagMatcher)); + + collectionRec.snippets = Snippet.records[queryScope] + .where(snippet => queryMatchers.every(matcher => matcher(snippet))) .flatPluck('id'); + } const slugPrefix = `/${collection.slug}`; const listingId = `collection${slugPrefix}`; Listing.createRecord({ id: listingId, relatedRecordId: collection.id, type: 'collection', + isTopLevel: collection.topLevel, slugPrefix, featuredIndex: featuredListings.indexOf(collection.slug), parent, diff --git a/src/blocks/extractor/index.js b/src/blocks/extractor/index.js index 2ac4e96d1..68b811be1 100644 --- a/src/blocks/extractor/index.js +++ b/src/blocks/extractor/index.js @@ -37,7 +37,6 @@ export class Extractor { contentConfigs, [...languageData.values()] ); - const tagData = Extractor.processTagData(contentConfigs, snippets); const { mainListing, collectionListing } = Extractor.extractHubConfig(contentDir); // Language data not passed here by design, pass only if needed @@ -69,7 +68,6 @@ export class Extractor { const { references, ...restData } = data; return { ...restData }; }), - tags: tagData, collectionListingConfig: Object.entries(collectionListing).reduce( (acc, [key, value]) => ({ ...acc, [`data${capitalize(key)}`]: value }), {} @@ -106,12 +104,23 @@ export class Extractor { const logger = new Logger('Extractor.extractCollectionConfigs'); logger.log('Extracting collection configurations'); const configs = YAMLHandler.fromGlob( - `${contentDir}/configs/collections/*.yaml`, + `${contentDir}/configs/collections/**/*.yaml`, { withNames: true } ).map(([path, config]) => { - const { snippetIds = [], ...rest } = config; - const id = path.split('/').slice(-1)[0].split('.')[0]; + const { + snippetIds = [], + name, + shortName = name, + topLevel = false, + ...rest + } = config; + const id = path + .replace(`${contentDir}/configs/collections/`, '') + .split('.')[0]; return { + name, + shortName, + topLevel, ...rest, snippetIds, id, @@ -156,74 +165,6 @@ export class Extractor { return languageData; }; - static processTagData = (contentConfigs, snippetData) => { - const logger = new Logger('Extractor.processTagData'); - logger.log('Processing tag data'); - const tagData = contentConfigs.reduce((acc, config) => { - const { isBlog, slug: configSlugPrefix, language } = config; - const snippets = snippetData.filter( - snippet => snippet.repository === config.id - ); - let snippetTags = uniqueElements( - snippets - .map(snippet => snippet.tags[0]) - .sort((a, b) => a.localeCompare(b)) - ); - if (config.isBlog) - snippetTags = snippetTags.filter( - tag => snippets.filter(s => s.tags.includes(tag)).length >= 10 - ); - - const tagData = snippetTags.map(tag => { - // TODO: Potentially configurable to resolve to an empty object instead - // of undefined to simplify checks using `||` - const tagMetadata = config.tagMetadata - ? config.tagMetadata[tag] - : undefined; - const name = - tagMetadata && tagMetadata.name - ? tagMetadata.name - : isBlog - ? literals.blogTag(tag) - : literals.codelangTag(language.name, tag); - const shortName = - tagMetadata && tagMetadata.shortName - ? tagMetadata.shortName - : tagMetadata && tagMetadata.name - ? tagMetadata.name - : isBlog - ? literals.shortBlogTag(tag) - : literals.shortCodelangTag(language.name, tag); - const description = - tagMetadata && tagMetadata.description - ? tagMetadata.description - : config.description; - const shortDescription = - tagMetadata && tagMetadata.shortDescription - ? tagMetadata.shortDescription - : config.shortDescription; - const splash = - tagMetadata && tagMetadata.splash - ? tagMetadata.splash - : config.splash; - const slugPrefix = `/${configSlugPrefix}/t/${tag}`; - return { - id: `${config.id}_${tag}`, - slugPrefix, - name, - shortName, - description, - shortDescription, - splash, - repository: config.id, - }; - }); - return [...acc, ...tagData]; - }, []); - logger.success('Finished processing tag data'); - return tagData; - }; - static extractSnippets = async (contentDir, contentConfigs, languageData) => { const logger = new Logger('Extractor.extractSnippets'); logger.log('Extracting snippets'); diff --git a/src/blocks/models/collection.js b/src/blocks/models/collection.js index ab0127c5b..e2c500718 100644 --- a/src/blocks/models/collection.js +++ b/src/blocks/models/collection.js @@ -8,10 +8,19 @@ export const collection = { { name: 'splash', type: 'stringRequired' }, { name: 'description', type: 'stringRequired' }, { name: 'shortDescription', type: 'stringRequired' }, + { name: 'topLevel', type: 'booleanRequired' }, ], + properties: { + hasParent: collection => Boolean(collection.listing.parent), + }, lazyProperties: { - listing: ({ models: { Listing } }) => collection => - Listing.records.get(`collection/${collection.slug}`), + listing: + ({ models: { Listing } }) => + collection => + Listing.records.get(`collection/${collection.slug}`), + }, + scopes: { + withParent: collection => collection.hasParent, }, - cacheProperties: ['listing'], + cacheProperties: ['listing', 'hasParent'], }; diff --git a/src/blocks/models/language.js b/src/blocks/models/language.js index 8630e4086..dcc7ad393 100644 --- a/src/blocks/models/language.js +++ b/src/blocks/models/language.js @@ -8,10 +8,8 @@ export const language = { properties: { slugPrefix: language => language.repository ? `/${language.repository.slug}` : null, - tagShortIds: language => - language.repository ? language.repository.tags.flatPluck('shortId') : [], }, - cacheProperties: ['slugPrefix', 'tagShortIds'], + cacheProperties: ['slugPrefix'], scopes: { // Hacky way to exclude the HTML language from the list full: language => language.id !== 'html', diff --git a/src/blocks/models/listing.js b/src/blocks/models/listing.js index 0b9858fce..8a029dba9 100644 --- a/src/blocks/models/listing.js +++ b/src/blocks/models/listing.js @@ -11,7 +11,7 @@ export const listing = { { name: 'type', type: 'enumRequired', - values: ['main', 'collections', 'blog', 'language', 'tag', 'collection'], + values: ['main', 'collections', 'collection'], }, { name: 'slugPrefix', type: 'stringRequired' }, { name: 'relatedRecordId', type: 'string' }, @@ -20,25 +20,22 @@ export const listing = { { name: 'dataSplash', type: 'string' }, { name: 'dataDescription', type: 'string' }, { name: 'dataFeaturedListings', type: 'stringArray' }, + { name: 'isTopLevel', type: 'booleanRequired' }, ], properties: { + hasParent: listing => Boolean(listing.parent), isMain: listing => listing.type === 'main', isCollections: listing => listing.type === 'collections', - isBlog: listing => listing.type === 'blog', + isBlog: listing => listing.id === 'collection/articles', isBlogTag: listing => - listing.type === 'tag' && listing.parent.type === 'blog', - isLanguage: listing => listing.type === 'language', - isTopLevel: listing => listing.isBlog || listing.isLanguage, - isTag: listing => listing.type === 'tag', + listing.hasParent && listing.parent === 'collection/articles', + isLanguage: listing => listing.isTopLevel && !listing.isBlog, isCollection: listing => listing.type === 'collection', - isParent: listing => Boolean(listing.children && listing.children.length), - isLeaf: listing => !listing.isParent, - isRoot: listing => !listing.parent, rootUrl: listing => - listing.parent ? listing.parent.slugPrefix : listing.slugPrefix, - siblings: listing => (listing.parent ? listing.parent.children : []), + listing.hasParent ? listing.parent.slugPrefix : listing.slugPrefix, + siblings: listing => (listing.hasParent ? listing.parent.children : []), siblingsExceptSelf: listing => - listing.parent ? listing.siblings.except(listing.id) : [], + listing.hasParent ? listing.siblings.except(listing.id) : [], // Used to determine the order of listings in the search index. ranking: listing => { if (listing.isCollections) return 0.8; @@ -46,30 +43,14 @@ export const listing = { listing.indexableContent ); // Demote tag listings to promote language and curated content - if (listing.isTag) return rankingValue * 0.25; + if (listing.hasParent) return rankingValue * 0.25; return rankingValue; }, - name: listing => { - if (listing.isMain) return listing.data.name; - if (listing.isCollections) return listing.data.name; - if (listing.isBlog) return 'Articles'; - if (listing.isLanguage) - return literals.codelang(listing.data.language.name); - if (listing.isCollection) return listing.data.name; - if (listing.isTag) return listing.data.name; - }, + name: listing => listing.data.name, // This is not used the way you think. // We use literals.tag to get the "short" name in sublinks. - // So what is this for? Listing preview cards and chips. - shortName: listing => { - if (listing.isMain) return listing.data.name; - if (listing.isCollections) return listing.data.name; - if (listing.isBlog) return 'Articles'; - if (listing.isLanguage) - return literals.shortCodelang(listing.data.language.name); - if (listing.isCollection) return listing.data.name; - if (listing.isTag) return listing.data.shortName; - }, + // So what is this for? Listing preview cards and chips. + shortName: listing => listing.data.name, description: listing => (listing.data ? listing.data.description : null), shortDescription: listing => { const shortDescription = @@ -80,21 +61,26 @@ export const listing = { }, slug: listing => `${listing.slugPrefix}/p/1`, splash: listing => (listing.data ? listing.data.splash : null), + // TODO: Probably short description here, shortId non-existent? + // TODO: Delete the pageDescription entries or something seoDescription: listing => literals.pageDescription(listing.type, { snippetCount: listing.snippets.length, listingLanguage: listing.data.language ? listing.data.language.name : '', - listingTag: listing.isTag ? listing.data.shortId : '', + listingTag: listing.hasParent ? listing.data.shortId : '', }), featured: listing => (listing.isMain ? 0 : listing.data.featured || 0), isListed: listing => { - const { type } = listing; - if (['blog', 'main', 'collection'].includes(type)) return true; - if (['language', 'tag'].includes(type)) return listing.featured > 0; - return false; + return listing.featured > 0; + // TODO: Figure it out? + // const { type } = listing; + // if (['blog', 'main', 'collection'].includes(type)) return true; + // if (['language'].includes(type)) return listing.featured > 0; + // return false; }, + // TODO: Needs to be updated, so searchable is part of config isSearchable: listing => Boolean( listing.isListed && @@ -106,10 +92,8 @@ export const listing = { // such as tags that inherit descriptions from the language parent. Worth // revisiting. searchTokens: listing => { - const { type } = listing; const uniqueDescription = - ['blog', 'collection', 'language', 'tag'].includes(type) && - listing.shortDescription + listing.isCollection && listing.shortDescription ? listing.shortDescription : ''; return uniqueElements( @@ -120,17 +104,10 @@ export const listing = { }, pageCount: listing => Math.ceil(listing.listedSnippets.length / CARDS_PER_PAGE), - defaultOrdering: listing => { - if (listing.isBlog || listing.isBlogTag) return 'new'; - if (listing.isCollection || listing.isCollections) return 'custom'; - return 'popularity'; - }, listedSnippets: listing => { - const order = listing.defaultOrdering; - if (order === 'new') return listing.snippets.listedByNew; - if (order === 'popularity') return listing.snippets.listedByPopularity; if (listing.isCollections) return listing.snippets; - // Catch all, also catches 'custom' for collection types + if (listing.isMain) return listing.snippets.listedByPopularity; + // Catch all, also catches collection types return listing.snippets.published; }, formattedSnippetCount: listing => @@ -140,7 +117,7 @@ export const listing = { }, lazyProperties: { data: - ({ models: { Repository, Tag, Collection } }) => + ({ models: { Collection } }) => listing => { if (listing.isMain) return { @@ -154,64 +131,28 @@ export const listing = { splash: listing.dataSplash, description: listing.dataDescription, }; - if (listing.isBlog) return Repository.records.blog.first; - if (listing.isLanguage) - return Repository.records.get(listing.relatedRecordId); - if (listing.isTag) return Tag.records.get(listing.relatedRecordId); - if (listing.isCollection) - return Collection.records.get(listing.relatedRecordId); - return {}; + return Collection.records.get(listing.relatedRecordId); }, snippets: ({ models: { Snippet, Listing } }) => listing => { if (listing.isMain) return Snippet.records; - if (listing.isBlog) return Snippet.records.blogs; // Abuse the snippets logic here a little bit to avoid having a new model // just for the collections listing. if (listing.isCollections) return Listing.records.featured; - if (listing.isLanguage) { - const { id: languageId } = listing.data.language; - return Snippet.records.filter(snippet => { - const snippetLanguageId = snippet.language - ? snippet.language.id - : null; - return snippetLanguageId === languageId; - }); - } - if (listing.isTag) { - const { shortId: tagId } = listing.data; - if (listing.isBlogTag) { - return Snippet.records.blogs.where(snippet => - snippet.tags.includes(tagId) - ); - } else { - const { id: languageId } = listing.data.repository.language; - return Snippet.records.filter(snippet => { - const snippetLanguageId = snippet.language - ? snippet.language.id - : null; - return ( - snippetLanguageId === languageId && snippet.tags.includes(tagId) - ); - }); - } - } - if (listing.isCollection) - return Snippet.records.only(...listing.data.snippets.flatPluck('id')); - return []; + return Snippet.records.only(...listing.data.snippets.flatPluck('id')); }, sublinks: ({ models: { Listing } }) => listing => { - if (listing.isCollection && !listing.parent) return []; + if (!listing.isTopLevel && !listing.parent) return []; if (listing.isCollections) return []; if (listing.isMain) { return [ ...Listing.records.language .sort((a, b) => b.ranking - a.ranking) .flatMap(ls => ({ - title: ls.shortName, + title: ls.shortName.replace(/ Snippets$/g, ''), url: `${ls.rootUrl}/p/1`, selected: false, })), @@ -227,24 +168,14 @@ export const listing = { { title: literals.tag('all'), url: `${listing.rootUrl}/p/1`, - selected: listing.isParent, + selected: listing.isTopLevel, }, ...links - .flatMap(link => - link.isCollection - ? { - title: link.data.shortName, - url: `/${link.data.slug}/p/1`, - selected: listing.isCollection && link.id === listing.id, - } - : { - title: literals.tag(link.data.shortId), - url: `${link.data.slugPrefix}/p/1`, - selected: - listing.isTag && - listing.data.shortId === link.data.shortId, - } - ) + .flatMap(link => ({ + title: link.data.shortName, + url: `/${link.data.slug}/p/1`, + selected: listing.isCollection && link.id === listing.id, + })) .sort((a, b) => a.title.localeCompare(b.title)), ]; }, @@ -260,16 +191,12 @@ export const listing = { cacheProperties: [ 'data', 'snippets', + 'hasParent', 'isMain', 'isBlog', 'isBlogTag', 'isLanguage', - 'isTopLevel', - 'isTag', 'isCollection', - 'isParent', - 'isLeaf', - 'isRoot', 'rootUrl', 'ranking', 'name', @@ -290,7 +217,7 @@ export const listing = { main: listing => listing.isMain, blog: listing => listing.isBlog, language: listing => listing.isLanguage, - tag: listing => listing.isTag, + tag: listing => listing.hasParent, collection: listing => listing.isCollection, listed: listing => listing.isListed, searchable: { diff --git a/src/blocks/models/page.js b/src/blocks/models/page.js index 4daa28c01..183849fe8 100644 --- a/src/blocks/models/page.js +++ b/src/blocks/models/page.js @@ -77,6 +77,7 @@ export const page = { .toArray() .slice(0, TOP_SNIPPET_CARDS * 5) ).slice(0, TOP_SNIPPET_CARDS); + // TODO: These are damn chips, I need to make it simpler context.featuredCollections = PreviewSerializer.serializeArray( listedCollections, { type: 'collection' } diff --git a/src/blocks/models/snippet.js b/src/blocks/models/snippet.js index a429f554b..452a90043 100644 --- a/src/blocks/models/snippet.js +++ b/src/blocks/models/snippet.js @@ -50,16 +50,10 @@ export const snippet = { else return snippet.formattedPrimaryTag; }, isBlog: snippet => snippet.type !== 'snippet', - isCSS: snippet => snippet.repository.isCSS, - isReact: snippet => snippet.repository.isReact, slug: snippet => `/${snippet.id}`, fileSlug: snippet => convertToSeoSlug(snippet.fileName.slice(0, -3)), url: snippet => `${snippet.repository.repoUrlPrefix}/${snippet.fileName}`, - actionType: snippet => { - if (snippet.isBlog) return undefined; - if (snippet.isCSS || snippet.isReact) return 'codepen'; - return 'copy'; - }, + actionType: snippet => (snippet.code ? 'codepen' : undefined), isScheduled: snippet => snippet.dateModified > new Date(), isPublished: snippet => !snippet.isScheduled, isListed: snippet => @@ -72,33 +66,40 @@ export const snippet = { year: 'numeric', }), searchTokensArray: snippet => { - const tokenizableElements = snippet.isBlog - ? [ - ...snippet.tags, - ...tokenizeSnippet( - stripMarkdownFormat(`${snippet.shortText} ${snippet.title}`) - ), - ] - : [ - snippet.fileName.slice(0, -3), - snippet.repository.language.short, - snippet.repository.language.long, - ...snippet.tags, - ...tokenizeSnippet( - stripMarkdownFormat(`${snippet.shortText} ${snippet.title}`) - ), - ]; - // Normalized title tokens, without stopword removal for special matches - // e.g. "this" in a relevant JS article needs to be matched when queried - tokenizableElements.push( + const tokenizableElements = [ + snippet.fileName.slice(0, -3), + ...snippet.tags, + ...tokenizeSnippet( + stripMarkdownFormat(`${snippet.shortText} ${snippet.title}`) + ), + // Normalized title tokens, without stopword removal for special matches + // e.g. "this" in a relevant JS article needs to be matched when queried ...snippet.title .toLowerCase() .trim() - .split(/[^a-z0-9\-']+/i) - ); + .split(/[^a-z0-9\-']+/i), + ]; + if (snippet.language) + tokenizableElements.push(snippet.language.short, snippet.language.long); + return uniqueElements(tokenizableElements.map(v => v.toLowerCase())); }, searchTokens: snippet => snippet.searchTokensArray.join(' '), + // TODO: This is still broken, check a DS snippet + primaryTagCollection: snippet => { + if (snippet.language) { + // Language slug prefix has a leading `/` + const primaryTagCollectionId = + `${snippet.language.slugPrefix}/${snippet.truePrimaryTag}`.slice(1); + const collection = snippet.collections.get(primaryTagCollectionId); + if (collection) return collection; + } + + if (snippet.hasCollection) { + const parentedCollection = snippet.collections.withParent.first; + if (parentedCollection) return parentedCollection; + } + }, breadcrumbs: snippet => { const homeCrumb = { url: '/', @@ -117,36 +118,12 @@ export const snippet = { }; let tagCrumb = null; - if (!snippet.isBlog) { - tagCrumb = { - url: `${ - snippet.language.slugPrefix - }/t/${snippet.primaryTag.toLowerCase()}/p/1`, - name: literals.tag(snippet.primaryTag), - }; - } else if ( - snippet.hasCollection && - snippet.collections.first.listing.parent - ) { - // TODO: Make this smarter to account for multiple collections - tagCrumb = { - url: `/${snippet.collections.first.slug}/p/1`, - name: snippet.collections.first.shortName, - }; - } else if ( - snippet.language && - snippet.truePrimaryTag && - snippet.language.tagShortIds.includes( - snippet.truePrimaryTag.toLowerCase() - ) - ) { + + if (snippet.primaryTagCollection) tagCrumb = { - url: `${ - snippet.language.slugPrefix - }/t/${snippet.truePrimaryTag.toLowerCase()}/p/1`, - name: literals.tag(snippet.truePrimaryTag), + url: `/${snippet.primaryTagCollection.slug}/p/1`, + name: snippet.primaryTagCollection.shortName, }; - } const snippetCrumb = { url: snippet.slug, @@ -197,8 +174,6 @@ export const snippet = { cacheProperties: [ 'ranking', 'isBlog', - 'isCSS', - 'isReact', 'isListed', 'isScheduled', 'isPublished', @@ -208,6 +183,7 @@ export const snippet = { 'language', 'primaryTag', 'truePrimaryTag', + 'primaryTagCollection', 'formattedPrimaryTag', 'fileSlug', 'seoTitle', diff --git a/src/blocks/models/tag.js b/src/blocks/models/tag.js deleted file mode 100644 index aee1ffbff..000000000 --- a/src/blocks/models/tag.js +++ /dev/null @@ -1,22 +0,0 @@ -export const tag = { - name: 'Tag', - fields: [ - { name: 'name', type: 'stringRequired' }, - { name: 'shortName', type: 'stringRequired' }, - { name: 'description', type: 'stringRequired' }, - { name: 'shortDescription', type: 'stringRequired' }, - { name: 'splash', type: 'stringRequired' }, - { name: 'slugPrefix', type: 'stringRequired' }, - ], - properties: { - isBlogTag: tag => tag.id.includes('blog_'), - shortId: tag => tag.id.split('_')[1], - language: tag => tag.repository.language, - featured: tag => tag.repository.featured, - }, - lazyProperties: { - listing: ({ models: { Listing } }) => tag => - Listing.records.get(`tag${tag.slugPrefix}`), - }, - cacheProperties: ['shortId', 'isBlogTag', 'language', 'featured', 'listing'], -}; diff --git a/src/blocks/schema.js b/src/blocks/schema.js index 85f007701..12079f47b 100644 --- a/src/blocks/schema.js +++ b/src/blocks/schema.js @@ -16,11 +16,6 @@ export const schema = { to: { model: 'Language', name: 'repository' }, type: 'oneToOne', }, - { - from: { model: 'Tag', name: 'repository' }, - to: { model: 'Repository', name: 'tags' }, - type: 'manyToOne', - }, { from: { model: 'Snippet', name: 'author' }, to: { model: 'Author', name: 'articles' }, diff --git a/src/blocks/serializers/searchResultSerializer.js b/src/blocks/serializers/searchResultSerializer.js index 13f20fca2..ea9d73275 100644 --- a/src/blocks/serializers/searchResultSerializer.js +++ b/src/blocks/serializers/searchResultSerializer.js @@ -1,8 +1,12 @@ export const searchResultSerializer = { name: 'SearchResultSerializer', methods: { + // TODO: This is quite a dirty hack to keep things consistent as before, but + // it needs a reiteration. title: (item, { type }) => - type === 'snippet' ? item.shortTitle : item.shortName, + type === 'snippet' + ? item.shortTitle + : item.shortName.replace(/ Snippets$/g, ''), tag: (item, { type }) => type === 'snippet' ? item.formattedMiniPreviewTag From 9b2e86d45982cc2d652ee16bb0707ca8c2285ad9 Mon Sep 17 00:00:00 2001 From: Angelos Chalaris Date: Sun, 7 May 2023 12:50:14 +0300 Subject: [PATCH 19/44] Introduce TagFormatter --- src/blocks/utilities/tagFormatter.js | 13 +++++++++++++ src/settings/tags.json | 15 +++++++-------- 2 files changed, 20 insertions(+), 8 deletions(-) create mode 100644 src/blocks/utilities/tagFormatter.js diff --git a/src/blocks/utilities/tagFormatter.js b/src/blocks/utilities/tagFormatter.js new file mode 100644 index 000000000..68fed1ad6 --- /dev/null +++ b/src/blocks/utilities/tagFormatter.js @@ -0,0 +1,13 @@ +import tagDictionary from 'settings/tags'; +import { capitalize } from 'utils'; + +/** + * Formats a tag for display. + */ +export class TagFormatter { + static format = tag => { + if (!tag.length) return ''; + if (tagDictionary[tag]) return tagDictionary[tag]; + return capitalize(tag); + }; +} diff --git a/src/settings/tags.json b/src/settings/tags.json index 21f1b557e..76ba7f8ea 100644 --- a/src/settings/tags.json +++ b/src/settings/tags.json @@ -1,10 +1,9 @@ { - "specialTagsDictionary" : { - "css": "CSS", - "javascript": "JavaScript", - "php": "PHP", - "seo": "SEO", - "vscode": "Visual Studio Code", - "html": "HTML" - } + "css": "CSS", + "javascript": "JavaScript", + "php": "PHP", + "seo": "SEO", + "vscode": "Visual Studio Code", + "html": "HTML", + "webdev": "Web development" } From 7118b852ba6b78fc6f11751b464081d5f7b308e7 Mon Sep 17 00:00:00 2001 From: Angelos Chalaris Date: Sun, 7 May 2023 12:51:18 +0300 Subject: [PATCH 20/44] Remove erroneous comments --- src/blocks/application.js | 4 ---- src/blocks/extractor/markdownParser.js | 1 - 2 files changed, 5 deletions(-) diff --git a/src/blocks/application.js b/src/blocks/application.js index 3c10d6bf5..a33f69894 100644 --- a/src/blocks/application.js +++ b/src/blocks/application.js @@ -555,7 +555,6 @@ export class Application { const logger = new Logger('Application.clearDataset'); logger.log('Clearing dataset...'); const dataset = Application.dataset; - // TODO: After jsiqle is ready, remove serialziers, too! if (dataset && dataset.name) { Application.modelNames.forEach(model => { dataset.removeModel(model); @@ -632,7 +631,6 @@ export class Application { * Reference: https://nodejs.org/api/repl.html#replstartoptions */ static _replWriter(object) { - // TODO: Improve highlighting of jsiqle objects const utilResult = util.inspect(object, { colors: true }); return utilResult .replace(/^Record\s+\[(.*?)#(.*?)]\s\{.*?}$/gm, (m, p1, p2) => { @@ -734,8 +732,6 @@ export class Application { }, }); - // TODO: Content create content source - logger.success('Setting up REPL commands complete.'); } diff --git a/src/blocks/extractor/markdownParser.js b/src/blocks/extractor/markdownParser.js index 5dd0e15e3..046d7e90b 100644 --- a/src/blocks/extractor/markdownParser.js +++ b/src/blocks/extractor/markdownParser.js @@ -205,7 +205,6 @@ export class MarkdownParser { if (languageObject && !languageObjects[languageName]) languageObjects[languageName] = languageObject; - // TODO: Add notranslate and translate=no to the inner pre node.value = isText ? [ `
    `, From 416f9bf0c33f13ae90161e4fb87eaf12f1f2aefb Mon Sep 17 00:00:00 2001 From: Angelos Chalaris Date: Sun, 7 May 2023 12:51:42 +0300 Subject: [PATCH 21/44] Create page models --- src/blocks/models/collectionPage.js | 81 ++++++++++++++++++++++++++ src/blocks/models/collectionsPage.js | 85 ++++++++++++++++++++++++++++ src/blocks/models/homePage.js | 43 ++++++++++++++ src/blocks/models/snippetPage.js | 56 ++++++++++++++++++ 4 files changed, 265 insertions(+) create mode 100644 src/blocks/models/collectionPage.js create mode 100644 src/blocks/models/collectionsPage.js create mode 100644 src/blocks/models/homePage.js create mode 100644 src/blocks/models/snippetPage.js diff --git a/src/blocks/models/collectionPage.js b/src/blocks/models/collectionPage.js new file mode 100644 index 000000000..e86122f64 --- /dev/null +++ b/src/blocks/models/collectionPage.js @@ -0,0 +1,81 @@ +import { Schemer } from 'blocks/utilities/schemer'; +import pathSettings from 'settings/paths'; + +export const collectionPage = { + name: 'CollectionPage', + fields: [ + { name: 'slug', type: 'stringRequired' }, + { name: 'pageNumber', type: 'numberRequired' }, + ], + properties: { + params: page => { + const [lang, ...listing] = page.slug.slice(1).split('/'); + return { lang, listing }; + }, + }, + lazyProperties: { + props: + ({ serializers: { PreviewSerializer } }) => + page => { + const collection = page.collection; + const context = {}; + + // TODO: These can have simpler names, update Astro, too + context.slug = page.slug; + context.listingName = collection.name; + context.listingDescription = collection.description; + context.listingCover = `/${pathSettings.staticAssetPath}/splash/${collection.splash}`; + context.listingSublinks = collection.sublinks; + context.pageDescription = collection.seoDescription; + + const pageNumber = page.pageNumber; + const totalPages = collection.pageCount; + const baseUrl = collection.slug; + let buttons = + totalPages === 2 + ? [1, 2] + : [ + 1, + Math.min(Math.max(pageNumber, 2), totalPages - 1), + totalPages, + ]; + context.paginator = + totalPages > 1 + ? { + previous: + pageNumber > 1 + ? { + url: `${baseUrl}/p/${pageNumber - 1}`, + label: 'Previous', + } + : null, + pages: buttons.map(buttonNumber => ({ + label: buttonNumber, + url: `${baseUrl}/p/${buttonNumber}`, + current: buttonNumber === pageNumber, + })), + next: + pageNumber < totalPages + ? { + url: `${baseUrl}/p/${pageNumber + 1}`, + label: 'Next', + } + : null, + } + : null; + + context.snippetList = PreviewSerializer.serializeArray( + page.snippets.toArray(), + { type: 'snippet' } + ); + + context.structuredData = Schemer.generateListingData({ + title: page.name, + slug: page.slug, + items: context.snippetList, + }); + + return context; + }, + }, +}; diff --git a/src/blocks/models/collectionsPage.js b/src/blocks/models/collectionsPage.js new file mode 100644 index 000000000..fefc3d91d --- /dev/null +++ b/src/blocks/models/collectionsPage.js @@ -0,0 +1,85 @@ +import { Schemer } from 'blocks/utilities/schemer'; +import pathSettings from 'settings/paths'; + +export const collectionPage = { + name: 'CollectionsPage', + fields: [ + { name: 'slug', type: 'stringRequired' }, + { name: 'name', type: 'stringRequired' }, + { name: 'description', type: 'stringRequired' }, + { name: 'shortDescription', type: 'stringRequired' }, + { name: 'splash', type: 'stringRequired' }, + { name: 'pageNumber', type: 'numberRequired' }, + { name: 'pageCount', type: 'numberRequired' }, + ], + properties: { + params: page => { + const [lang, ...listing] = page.slug.slice(1).split('/'); + return { lang, listing }; + }, + baseSlug: page => page.slug.replace(/\/p\/\d+$/, ''), + }, + lazyProperties: { + props: + ({ serializers: { PreviewSerializer } }) => + page => { + const context = {}; + // TODO: These can have simpler names, update Astro, too + context.slug = page.slug; + context.listingName = page.name; + context.listingDescription = page.description; + context.listingCover = `/${pathSettings.staticAssetPath}/splash/${page.splash}`; + context.pageDescription = page.shortDescription; + + const pageNumber = page.pageNumber; + const totalPages = page.pageCount; + const baseUrl = page.baseSlug; + let buttons = + totalPages === 2 + ? [1, 2] + : [ + 1, + Math.min(Math.max(pageNumber, 2), totalPages - 1), + totalPages, + ]; + context.paginator = + totalPages > 1 + ? { + previous: + pageNumber > 1 + ? { + url: `${baseUrl}/p/${pageNumber - 1}`, + label: 'Previous', + } + : null, + pages: buttons.map(buttonNumber => ({ + label: buttonNumber, + url: `${baseUrl}/p/${buttonNumber}`, + current: buttonNumber === pageNumber, + })), + next: + pageNumber < totalPages + ? { + url: `${baseUrl}/p/${pageNumber + 1}`, + label: 'Next', + } + : null, + } + : null; + + context.snippetList = PreviewSerializer.serializeArray( + page.collections.toArray(), + { type: 'collection' } + ); + + context.structuredData = Schemer.generateListingData({ + title: page.name, + slug: page.slug, + items: context.snippetList, + }); + + return context; + }, + }, + cacheProperties: ['baseSlug'], +}; diff --git a/src/blocks/models/homePage.js b/src/blocks/models/homePage.js new file mode 100644 index 000000000..9bcaca0a0 --- /dev/null +++ b/src/blocks/models/homePage.js @@ -0,0 +1,43 @@ +import { Schemer } from 'blocks/utilities/schemer'; +import settings from 'settings/global'; +import presentationSettings from 'settings/presentation'; + +export const homePage = { + name: 'HomePage', + fields: [ + { name: 'slug', type: 'stringRequired' }, + { name: 'snippetCount', type: 'numberRequired' }, + ], + properties: { + params: () => undefined, + }, + lazyProperties: { + props: + ({ serializers: { PreviewSerializer } }) => + page => { + const context = {}; + + context.featuredCollections = PreviewSerializer.serializeArray( + page.collections.toArray(), + { type: 'collection' } + ); + context.featuredCollections.push({ + title: 'More collections', + url: '/collections/p/1', + selected: false, + }); + + context.featuredSnippets = PreviewSerializer.serializeArray( + page.snippets.toArray(), + { type: 'snippet' } + ); + + context.splashImage = presentationSettings.homePageSplashImage; + context.snippetListUrl = '/list/p/1'; + context.pageDescription = `Browse ${page.snippetCount} short code snippets for all your development needs on ${settings.websiteName}.`; + context.structuredData = Schemer.generateHomeData(); + + return context; + }, + }, +}; diff --git a/src/blocks/models/snippetPage.js b/src/blocks/models/snippetPage.js new file mode 100644 index 000000000..2820b90bd --- /dev/null +++ b/src/blocks/models/snippetPage.js @@ -0,0 +1,56 @@ +import { Schemer } from 'blocks/utilities/schemer'; + +export const snippetPage = { + name: 'SnippetPage', + fields: [{ name: 'slug', type: 'stringRequired' }], + properties: { + params: page => { + const segments = page.slug.slice(1).split('/'); + return { + lang: segments[0], + snippet: segments.slice(-1)[0], + }; + }, + }, + lazyProperties: { + props: + ({ serializers: { SnippetContextSerializer, PreviewSerializer } }) => + page => { + const snippet = page.snippet; + const context = {}; + + context.breadcrumbs = snippet.breadcrumbs; + context.pageDescription = snippet.seoDescription; + context.snippet = SnippetContextSerializer.serialize(snippet); + + let recommendedItems = PreviewSerializer.serializeArray( + snippet.recommendedSnippets.toArray(), + { type: 'snippet' } + ); + + if (snippet.recommendedCollection) { + recommendedItems.unshift( + PreviewSerializer.serialize(snippet.recommendedCollection, { + type: 'collection', + }) + ); + } + + context.recommendations = recommendedItems; + + context.structuredData = Schemer.generateSnippetData({ + title: snippet.seoTitle, + slug: snippet.slug, + description: snippet.shortText, + cover: context.snippet.cover, + dateModified: snippet.dateModified, + author: snippet.author, + }); + + return context; + }, + }, + scopes: { + published: page => page.snippet.published, + }, +}; From 95dcdc4db4c792bd9fac010b1610ba4014cf4432 Mon Sep 17 00:00:00 2001 From: Angelos Chalaris Date: Sun, 7 May 2023 12:51:58 +0300 Subject: [PATCH 22/44] Add presentation settings --- src/settings/presentation.json | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 src/settings/presentation.json diff --git a/src/settings/presentation.json b/src/settings/presentation.json new file mode 100644 index 000000000..e914360ca --- /dev/null +++ b/src/settings/presentation.json @@ -0,0 +1,7 @@ +{ + "cardsPerPage": 15, + "newSnippetCards": 5, + "topSnippetCards": 5, + "topCollectionChips": 8, + "homePageSplashImage": "/assets/splash/work-sunrise.png" +} From eb503a842a20eb845ec458fbeff15f1ecf23d765 Mon Sep 17 00:00:00 2001 From: Angelos Chalaris Date: Sun, 7 May 2023 12:52:31 +0300 Subject: [PATCH 23/44] Add glob and new keys for TextParser output --- src/blocks/extractor/textParser.js | 12 ++++++++++++ src/test/blocks/extractor/textParser.test.js | 2 ++ 2 files changed, 14 insertions(+) diff --git a/src/blocks/extractor/textParser.js b/src/blocks/extractor/textParser.js index e1ac8505f..9f6833328 100644 --- a/src/blocks/extractor/textParser.js +++ b/src/blocks/extractor/textParser.js @@ -1,5 +1,6 @@ import { readFile, readdir } from 'fs/promises'; import frontmatter from 'front-matter'; +import glob from 'glob'; /** * Parses text files, using frontmatter, returning text objects. @@ -17,6 +18,7 @@ export class TextParser { const { dateModified = '2021-06-13T05:00:00-04:00', author = null, + language = null, ...restAttributes } = attributes; return { @@ -24,7 +26,9 @@ export class TextParser { ...restAttributes, dateModified: new Date(dateModified), author, + language, fileName, + filePath, }; }); }; @@ -38,4 +42,12 @@ export class TextParser { fileNames.map(f => TextParser.fromPath(`${dirPath}/${f}`)) ); }; + + static fromGlob = async globPattern => { + const fileNames = glob + .sync(globPattern) + .sort((a, b) => (a.toLowerCase() < b.toLowerCase() ? -1 : 1)); + + return Promise.all(fileNames.map(f => TextParser.fromPath(f))); + }; } diff --git a/src/test/blocks/extractor/textParser.test.js b/src/test/blocks/extractor/textParser.test.js index 512c8278f..4c5db6603 100644 --- a/src/test/blocks/extractor/textParser.test.js +++ b/src/test/blocks/extractor/textParser.test.js @@ -18,6 +18,8 @@ describe('TextParser', () => { [ 'body', 'fileName', + 'filePath', + 'language', 'tags', 'title', 'dateModified', From ef665f435880d211dc0c053411489b9b4d3aa98b Mon Sep 17 00:00:00 2001 From: Angelos Chalaris Date: Sun, 7 May 2023 12:53:28 +0300 Subject: [PATCH 24/44] Update models, rewrite most of the logic --- src/blocks/application.js | 225 ++++++++++++---------------- src/blocks/models/collection.js | 124 ++++++++++++++- src/blocks/models/language.js | 9 -- src/blocks/models/snippet.js | 169 ++++++++++++--------- src/blocks/schema.js | 49 ++++-- src/blocks/utilities/recommender.js | 2 +- src/blocks/utilities/schemer.js | 11 +- 7 files changed, 350 insertions(+), 239 deletions(-) diff --git a/src/blocks/application.js b/src/blocks/application.js index a33f69894..5ac6affba 100644 --- a/src/blocks/application.js +++ b/src/blocks/application.js @@ -9,6 +9,7 @@ import { JSONHandler } from 'blocks/utilities/jsonHandler'; import { YAMLHandler } from 'blocks/utilities/yamlHandler'; import { Extractor } from 'blocks/extractor'; import { Content } from 'blocks/utilities/content'; +import { shuffle } from 'utils'; /** * The application class acts much like a monolith for all the shared logic and @@ -123,10 +124,9 @@ export class Application { } // ------------------------------------------------------------- - // Settings and literals dynamic loading methods and getters + // Settings dynamic loading methods and getters // ------------------------------------------------------------- static _settings = {}; - static _literals = {}; /** * Dynamically loads/reloads settings from the src/settings directory. @@ -156,27 +156,6 @@ export class Application { return Application._settings; } - /** - * Dynamically loads/reloads literals from the src/lang/en/index.js file. - */ - static loadLiterals() { - const logger = new Logger('Application.loadLiterals'); - logger.log('Loading literals from src/lang/en/index.js...'); - Application._literals = { - ...require(path.resolve('src/lang/en/index.js')).default, - }; - // TODO: Dynamically import and merge under client key all client literals - logger.success('Settings loading complete.'); - } - - /** - * Returns the literals object. - */ - static get literals() { - if (!Object.keys(Application._literals).length) Application.loadLiterals(); - return Application._literals; - } - // ------------------------------------------------------------- // Schema, model, serializer dynamic loading methods and getters // ------------------------------------------------------------- @@ -393,21 +372,27 @@ export class Application { static populateDataset() { const logger = new Logger('Application.populateDataset'); logger.log('Populating dataset...'); - const { Snippet, Repository, Collection, Listing, Language, Author, Page } = - Application.models; const { - snippets, - repositories, - collections, - languages, - authors, - mainListingConfig, - collectionListingConfig, - } = Application.datasetObject; - const { dataFeaturedListings: featuredListings } = collectionListingConfig; - - // Populate repos, languages, authors, snippets - repositories.forEach(repo => Repository.createRecord(repo)); + Snippet, + Collection, + Language, + Author, + SnippetPage, + CollectionPage, + CollectionsPage, + HomePage, + } = Application.models; + const { snippets, collections, languages, authors, collectionsHub } = + Application.datasetObject; + const { featuredListings } = collectionsHub; + const { + cardsPerPage, + newSnippetCards, + topSnippetCards, + topCollectionChips, + } = Application.settings.presentation; + + // Populate languages, authors, snippets languages.forEach(language => Language.createRecord(language)); authors.forEach(author => Author.createRecord(author)); snippets.forEach(snippet => { @@ -418,7 +403,7 @@ export class Application { }); }); - // Populate collections, collection listings and link to snippets and parent listings + // Populate collections collections.forEach(collection => { const { snippetIds, @@ -428,14 +413,24 @@ export class Application { parent, ...rest } = collection; - const collectionRec = Collection.createRecord(rest); + const collectionRec = Collection.createRecord({ + parent, + featuredIndex: featuredListings.indexOf(collection.id), + ...rest, + }); if (snippetIds && snippetIds.length) collectionRec.snippets = snippetIds; - else { + else if (collection.id === 'list') { + // Use listedBy in main listing to exclude unlisted snippets + collectionRec.snippets = + Snippet.records.listedByPopularity.flatPluck('id'); + } else { const queryMatchers = []; - let queryScope = 'listedByPopularity'; + // Use publishedBy in other listings to include unlisted snippets in order + // to allow for proper breadcrumbs to form for them + let queryScope = 'publishedByPopularity'; if (typeMatcher) if (typeMatcher === 'article') { - queryScope = 'listedByNew'; + queryScope = 'publishedByNew'; queryMatchers.push(snippet => snippet.type !== 'snippet'); } else queryMatchers.push(snippet => snippet.type === typeMatcher); if (languageMatcher) @@ -450,101 +445,81 @@ export class Application { .where(snippet => queryMatchers.every(matcher => matcher(snippet))) .flatPluck('id'); } - const slugPrefix = `/${collection.slug}`; - const listingId = `collection${slugPrefix}`; - Listing.createRecord({ - id: listingId, - relatedRecordId: collection.id, - type: 'collection', - isTopLevel: collection.topLevel, - slugPrefix, - featuredIndex: featuredListings.indexOf(collection.slug), - parent, - }); - }); - // Populate the main listing - Listing.createRecord({ - id: 'main', - type: 'main', - slugPrefix: '/list', - featuredIndex: -1, - ...mainListingConfig, }); - // Populate the collection listing - Listing.createRecord({ - id: 'collections', - type: 'collections', - slugPrefix: '/collections', - featuredIndex: -1, - ...collectionListingConfig, - }); - // Populate snipet pages Snippet.records.forEach(snippet => { const { id } = snippet; - Page.createRecord({ - id: `snippet_${id}`, - type: 'snippet', - relatedRecordId: id, + SnippetPage.createRecord({ + id: `$${id}`, + slug: snippet.slug, + snippet: id, }); }); - // Populate listing pages - Listing.records.forEach(listing => { - const { id } = listing; - const itemsName = listing.isCollections ? 'listings' : 'snippets'; - // TODO: Move this to settings and update listing! - const CARDS_PER_PAGE = 15; + // Populate collection pages + Collection.records.forEach(collection => { + const { id: collectionId } = collection; let pageCounter = 1; const snippetIterator = - listing.listedSnippets.batchIterator(CARDS_PER_PAGE); + collection.listedSnippets.batchIterator(cardsPerPage); for (let pageSnippets of snippetIterator) { - Page.createRecord({ - id: `listing_${id}_${pageCounter}`, - type: 'listing', - relatedRecordId: id, - [itemsName]: pageSnippets.flatPluck('id'), + const id = `${collectionId}/p/${pageCounter}`; + CollectionPage.createRecord({ + id: `$${id}`, + collection: collectionId, + slug: `/${id}`, + snippets: pageSnippets.flatPluck('id'), pageNumber: pageCounter, }); pageCounter++; } }); - // Populate static pages - Page.createRecord({ - id: '404', - type: 'notfound', - slug: '/404', - staticPriority: 0, - }); - Page.createRecord({ - id: 'about', - type: 'static', - slug: '/about', - staticPriority: 0.25, - }); - Page.createRecord({ - id: 'cookies', - type: 'static', - slug: '/cookies', - staticPriority: 0.25, - }); - Page.createRecord({ - id: 'faq', - type: 'static', - slug: '/faq', - staticPriority: 0.25, - }); - Page.createRecord({ - id: 'search', - type: 'search', - slug: '/search', - staticPriority: 0.25, - }); - // Populate the home page - Page.createRecord({ - id: 'home', - type: 'static', - slug: '/', - staticPriority: 1.0, - }); + // Populate collections list pages + { + const collectionId = 'collections'; + let pageCounter = 1; + const collections = Collection.records.featured; + const totalPages = Math.ceil(collections.length / cardsPerPage); + const collectionIterator = collections.batchIterator(cardsPerPage); + for (let pageCollections of collectionIterator) { + const id = `${collectionId}/p/${pageCounter}`; + CollectionsPage.createRecord({ + id: `$${id}`, + slug: `/${id}`, + name: collectionsHub.name, + description: collectionsHub.description, + shortDescription: collectionsHub.shortDescription, + splash: collectionsHub.splash, + collections: pageCollections.flatPluck('id'), + pageCount: totalPages, + pageNumber: pageCounter, + }); + pageCounter++; + } + } + // Populate home page + { + const id = 'index'; + const collections = Collection.records.featured + .slice(0, topCollectionChips) + .flatPluck('id'); + const newSnippets = Snippet.records.listedByNew + .slice(0, newSnippetCards) + .flatPluck('id'); + const topSnippets = shuffle( + Snippet.records.listedByPopularity + .slice(0, topSnippetCards * 5) + .flatPluck('id') + ); + HomePage.createRecord({ + id: `$${id}`, + slug: `/${id}`, + snippetCount: Snippet.records.published.length, + collections, + snippets: [...new Set([...newSnippets, ...topSnippets])].slice( + 0, + newSnippetCards + topSnippetCards + ), + }); + } logger.success('Populating dataset complete.'); } @@ -588,7 +563,6 @@ export class Application { const logger = new Logger('Application.initialize'); logger.log(`Starting application in "${process.env.NODE_ENV}" mode.`); Application.loadSettings(); - Application.loadLiterals(); Application.setupSchema(); if (data) { logger.log('Using provided dataset.'); @@ -663,7 +637,6 @@ export class Application { const context = Application._replServer.context; context.Application = Application; context.settings = Application.settings; - context.literals = Application.literals; context.glob = glob; context.chalk = chalk; // NOTE: We are not exactly clearing existing context, so there diff --git a/src/blocks/models/collection.js b/src/blocks/models/collection.js index e2c500718..10aa0606e 100644 --- a/src/blocks/models/collection.js +++ b/src/blocks/models/collection.js @@ -1,26 +1,138 @@ +import { uniqueElements } from 'utils'; +import tokenizeCollection from 'utils/search'; +import { Ranker } from 'blocks/utilities/ranker'; +import presentationSettings from 'settings/presentation'; + +// TODO: Add redirects for previous articles collections +// TODO: Only concern is the sitemap logic, but that can be extracted later or we can +// try the Astro plugin, I guess. +// As priority and changefreq are ignored by Google, the ranking of all pages +// won't be a concern and we can simplify the generation by a whole lot. export const collection = { name: 'Collection', fields: [ { name: 'name', type: 'stringRequired' }, { name: 'shortName', type: 'stringRequired' }, + { name: 'miniName', type: 'stringRequired' }, { name: 'slug', type: 'stringRequired' }, { name: 'featured', type: 'booleanRequired' }, + { name: 'featuredIndex', type: 'number' }, { name: 'splash', type: 'stringRequired' }, { name: 'description', type: 'stringRequired' }, { name: 'shortDescription', type: 'stringRequired' }, + { name: 'seoDescription', type: 'stringRequired' }, { name: 'topLevel', type: 'booleanRequired' }, ], properties: { - hasParent: collection => Boolean(collection.listing.parent), + hasParent: collection => Boolean(collection.parent), + isMain: collection => collection.id === 'list', + isPrimary: collection => collection.topLevel, + isSecondary: collection => collection.hasParent, + rootUrl: collection => + collection.hasParent ? collection.parent.slug : collection.slug, + siblings: collection => + collection.hasParent ? collection.parent.children : [], + siblingsExceptSelf: collection => + collection.hasParent ? collection.siblings.except(collection.id) : [], + ranking: collection => + Ranker.rankIndexableContent(collection.indexableContent), + isSearchable: collection => Boolean(collection.featured), + searchTokens: collection => { + const uniqueDescription = collection.shortDescription || ''; + return uniqueElements( + tokenizeCollection(`${uniqueDescription} ${collection.name}`).map(v => + v.toLowerCase() + ) + ).join(' '); + }, + firstPageSlug: collection => `${collection.slug}/p/1`, + pageCount: collection => + Math.ceil( + collection.listedSnippets.length / presentationSettings.cardsPerPage + ), + listedSnippets: collection => collection.snippets.listed.published, + formattedSnippetCount: collection => + `${collection.listedSnippets.length} snippets`, + indexableContent: collection => + [collection.name, collection.description, collection.shortDescription] + .filter(Boolean) + .join(' ') + .toLowerCase(), }, lazyProperties: { - listing: - ({ models: { Listing } }) => - collection => - Listing.records.get(`collection/${collection.slug}`), + sublinks: + ({ models: { Collection } }) => + collection => { + if (collection.isMain) { + return [ + ...Collection.records.primary + .sort((a, b) => b.ranking - a.ranking) + .flatMap(c => ({ + title: c.miniName, + url: c.firstPageSlug, + selected: false, + })), + { + title: 'More collections', + url: '/collections/p/1', + selected: false, + }, + ]; + } + + if (!collection.isPrimary && !collection.hasParent) return []; + const links = collection.hasParent + ? collection.siblings + : collection.children; + return [ + { + title: 'All', + url: `${collection.rootUrl}/p/1`, + selected: collection.isPrimary, + }, + ...links + .flatMap(link => ({ + title: link.miniName, + url: link.firstPageSlug, + selected: link.id === collection.id, + })) + .sort((a, b) => a.title.localeCompare(b.title)), + ]; + }, + }, + cacheProperties: [ + 'hasParent', + 'isMain', + 'isPrimary', + 'isSecondary', + 'siblings', + 'siblignsExceptSelf', + 'ranking', + 'seoDescription', + 'isSearchable', + 'searchTokens', + 'firstPageSlug', + 'pageCount', + 'listedSnippets', + 'formattedSnippetCount', + 'indexableContent', + ], + methods: { + // A little fiddly, but should work for the time being + matchesTag: (collection, tag) => collection.id.endsWith(`/${tag}`), }, scopes: { withParent: collection => collection.hasParent, + primary: collection => collection.isPrimary, + secondary: collection => collection.isSecondary, + listed: collection => collection.isSearchable, + ranked: { + matcher: collection => collection.isSearchable, + sorter: (a, b) => b.ranking - a.ranking, + }, + featured: { + matcher: listing => listing.featuredIndex !== -1, + sorter: (a, b) => a.featuredIndex - b.featuredIndex, + }, }, - cacheProperties: ['listing', 'hasParent'], }; diff --git a/src/blocks/models/language.js b/src/blocks/models/language.js index dcc7ad393..df42656bb 100644 --- a/src/blocks/models/language.js +++ b/src/blocks/models/language.js @@ -5,13 +5,4 @@ export const language = { { name: 'short', type: 'stringRequired' }, { name: 'name', type: 'stringRequired' }, ], - properties: { - slugPrefix: language => - language.repository ? `/${language.repository.slug}` : null, - }, - cacheProperties: ['slugPrefix'], - scopes: { - // Hacky way to exclude the HTML language from the list - full: language => language.id !== 'html', - }, }; diff --git a/src/blocks/models/snippet.js b/src/blocks/models/snippet.js index 452a90043..7f8cea4d1 100644 --- a/src/blocks/models/snippet.js +++ b/src/blocks/models/snippet.js @@ -1,8 +1,8 @@ import { convertToSeoSlug, uniqueElements, stripMarkdownFormat } from 'utils'; import { Ranker } from 'blocks/utilities/ranker'; import { Recommender } from 'blocks/utilities/recommender'; +import { TagFormatter } from 'blocks/utilities/tagFormatter'; import tokenizeSnippet from 'utils/search'; -import literals from 'lang/en'; export const snippet = { name: 'Snippet', @@ -29,19 +29,13 @@ export const snippet = { return `${snippet.language.name} - ${snippet.title}`; }, primaryTag: snippet => snippet.tags[0], - truePrimaryTag: snippet => { - if (!snippet.isBlog) return snippet.primaryTag; - const language = snippet.language; - if (!language) return snippet.primaryTag; - return snippet.tags.filter(t => t !== language.id)[0]; - }, - formattedPrimaryTag: snippet => literals.tag(snippet.truePrimaryTag), + formattedPrimaryTag: snippet => TagFormatter.format(snippet.primaryTag), // Used for snippet previews in search autocomplete formattedMiniPreviewTag: snippet => - snippet.isBlog && !snippet.language ? 'Article' : snippet.language.name, + snippet.language ? snippet.language.name : 'Article', formattedTags: snippet => { - let tags = snippet.tags.map(literals.tag); - if (!snippet.isBlog) tags.unshift(snippet.language.name); + let tags = snippet.tags.map(TagFormatter.format); + if (snippet.language) tags.unshift(snippet.language.name); return tags.join(', '); }, formattedPreviewTags: snippet => { @@ -49,15 +43,14 @@ export const snippet = { return [snippet.language.name, snippet.formattedPrimaryTag].join(', '); else return snippet.formattedPrimaryTag; }, - isBlog: snippet => snippet.type !== 'snippet', slug: snippet => `/${snippet.id}`, fileSlug: snippet => convertToSeoSlug(snippet.fileName.slice(0, -3)), - url: snippet => `${snippet.repository.repoUrlPrefix}/${snippet.fileName}`, + url: snippet => + `https://github.com/30-seconds/30-seconds-of-code/blob/master${snippet.slug}.md`, actionType: snippet => (snippet.code ? 'codepen' : undefined), isScheduled: snippet => snippet.dateModified > new Date(), isPublished: snippet => !snippet.isScheduled, - isListed: snippet => - snippet.repository.featured && snippet.listed && !snippet.isScheduled, + isListed: snippet => snippet.listed && !snippet.isScheduled, ranking: snippet => Ranker.rankIndexableContent(snippet.indexableContent), dateFormatted: snippet => snippet.dateModified.toLocaleDateString('en-US', { @@ -85,20 +78,55 @@ export const snippet = { return uniqueElements(tokenizableElements.map(v => v.toLowerCase())); }, searchTokens: snippet => snippet.searchTokensArray.join(' '), - // TODO: This is still broken, check a DS snippet - primaryTagCollection: snippet => { - if (snippet.language) { - // Language slug prefix has a leading `/` - const primaryTagCollectionId = - `${snippet.language.slugPrefix}/${snippet.truePrimaryTag}`.slice(1); - const collection = snippet.collections.get(primaryTagCollectionId); - if (collection) return collection; - } + orderedCollections: snippet => { + const orderedCollections = []; + + const primaryCollections = snippet.collections.primary; + const allSecondaryCollections = snippet.collections.secondary; + const mainSecondaryCollection = allSecondaryCollections.length + ? allSecondaryCollections.find(collection => + collection.matchesTag(snippet.primaryTag) + ) + : undefined; + const secondaryCollections = mainSecondaryCollection + ? allSecondaryCollections.except(mainSecondaryCollection.id) + : allSecondaryCollections; + const otherCollections = snippet.collections.except( + 'list', // Exclude main listing from breadcrumbs + ...primaryCollections.flatPluck('id'), + ...allSecondaryCollections.flatPluck('id') + ); + + // We don't expect to have multiple primary collections + if (primaryCollections.length) + orderedCollections.push(primaryCollections.first); + + if (mainSecondaryCollection) + orderedCollections.push(mainSecondaryCollection); + + if (secondaryCollections.length) + orderedCollections.push(...secondaryCollections.toArray()); + + if (otherCollections.length) + orderedCollections.push(...otherCollections.toArray()); + + return orderedCollections; + }, + breadcrumbCollectionIds: snippet => { + if (!snippet.hasCollection) return []; - if (snippet.hasCollection) { - const parentedCollection = snippet.collections.withParent.first; - if (parentedCollection) return parentedCollection; + const ids = []; + if (snippet.orderedCollections[0]) { + // Has both primary and secondary + if (snippet.orderedCollections[0].isPrimary) + ids.push(snippet.orderedCollections[0].id); + if (snippet.orderedCollections[1]) + ids.push(snippet.orderedCollections[1].id); + } else { + // Only has secondary, use one + ids.push(snippet.orderedCollections[0].id); } + return ids; }, breadcrumbs: snippet => { const homeCrumb = { @@ -106,38 +134,24 @@ export const snippet = { name: 'Home', }; - const languageCrumb = - snippet.language && snippet.language.id !== 'html' - ? { - url: `${snippet.language.slugPrefix}/p/1`, - name: snippet.language.name, - } - : { - url: `/articles/p/1`, - name: literals.blog, - }; - - let tagCrumb = null; - - if (snippet.primaryTagCollection) - tagCrumb = { - url: `/${snippet.primaryTagCollection.slug}/p/1`, - name: snippet.primaryTagCollection.shortName, - }; + const collectionCrumbs = snippet.collections + .only(...snippet.breadcrumbCollectionIds) + .flatMap(collection => ({ + url: collection.firstPageSlug, + name: collection.miniName, + })); const snippetCrumb = { url: snippet.slug, name: snippet.shortTitle, }; - return [homeCrumb, languageCrumb, tagCrumb, snippetCrumb].filter(Boolean); + return [homeCrumb, ...collectionCrumbs, snippetCrumb].filter(Boolean); }, - hasCollection: snippet => - Boolean(snippet.collections && snippet.collections.length), + hasCollection: snippet => Boolean(snippet.collections.length), recommendedCollection: snippet => - snippet.hasCollection && !snippet.collections.first.listing.parent - ? snippet.collections.first - : null, + snippet.collections.except(...snippet.breadcrumbCollectionIds).ranked + .first, indexableContent: snippet => [ snippet.title, @@ -151,16 +165,6 @@ export const snippet = { .toLowerCase(), }, lazyProperties: { - language: - ({ models: { Language } }) => - snippet => { - if (!snippet.isBlog) return snippet.repository.language; - for (let tag of snippet.tags) { - const lang = Language.records.get(tag); - if (lang) return lang; - } - return null; - }, recommendedSnippets: ({ models: { Snippet } }) => snippet => { @@ -172,25 +176,35 @@ export const snippet = { }, }, cacheProperties: [ - 'ranking', - 'isBlog', - 'isListed', + 'seoTitle', + 'primaryTag', + 'formattedPrimaryTag', + 'formattedMiniPreviewTag', + 'formattedTags', + 'formattedPreviewTags', + 'slug', + 'fileSlug', 'isScheduled', 'isPublished', + 'isListed', + 'ranking', 'dateFormatted', 'searchTokensArray', 'searchTokens', - 'language', - 'primaryTag', - 'truePrimaryTag', - 'primaryTagCollection', - 'formattedPrimaryTag', - 'fileSlug', - 'seoTitle', + 'orderedCollections', + 'breadcrumbCollectionIds', + 'hasCollection', ], scopes: { - snippets: snippet => snippet.type === 'snippet', - blogs: snippet => snippet.type !== 'snippet', + allByPopularity: { + matcher: () => true, + sorter: (a, b) => b.ranking - a.ranking, + }, + allByNew: { + matcher: () => true, + sorter: (a, b) => b.dateModified - a.dateModified, + }, + unlisted: snippet => !snippet.isListed, listed: snippet => snippet.isListed, listedByPopularity: { matcher: snippet => snippet.isListed, @@ -200,8 +214,15 @@ export const snippet = { matcher: snippet => snippet.isListed, sorter: (a, b) => b.dateModified - a.dateModified, }, - unlisted: snippet => !snippet.isListed, scheduled: snippet => snippet.isScheduled, published: snippet => snippet.isPublished, + publishedByPopularity: { + matcher: snippet => snippet.isPublished, + sorter: (a, b) => b.ranking - a.ranking, + }, + publishedByNew: { + matcher: snippet => snippet.isPublished, + sorter: (a, b) => b.dateModified - a.dateModified, + }, }, }; diff --git a/src/blocks/schema.js b/src/blocks/schema.js index 12079f47b..b37c1829c 100644 --- a/src/blocks/schema.js +++ b/src/blocks/schema.js @@ -1,9 +1,15 @@ export const schema = { name: 'WebData', relationships: [ + // Main models { - from: { model: 'Snippet', name: 'repository' }, - to: { model: 'Repository', name: 'snippets' }, + from: { model: 'Snippet', name: 'author' }, + to: { model: 'Author', name: 'articles' }, + type: 'manyToOne', + }, + { + from: { model: 'Snippet', name: 'language' }, + to: { model: 'Language', name: 'snippets' }, type: 'manyToOne', }, { @@ -12,29 +18,40 @@ export const schema = { type: 'manyToMany', }, { - from: { model: 'Repository', name: 'language' }, - to: { model: 'Language', name: 'repository' }, - type: 'oneToOne', + from: { model: 'Collection', name: 'parent' }, + to: { model: 'Collection', name: 'children' }, + type: 'manyToOne', }, + // Page models (always refer the main model from the page model, not the other way around) { - from: { model: 'Snippet', name: 'author' }, - to: { model: 'Author', name: 'articles' }, - type: 'manyToOne', + from: { model: 'SnippetPage', name: 'snippet' }, + to: { model: 'Snippet', name: 'page' }, + type: 'oneToOne', }, { - from: { model: 'Listing', name: 'parent' }, - to: { model: 'Listing', name: 'children' }, - type: 'manyToOne', + from: { model: 'CollectionPage', name: 'collection' }, + to: { model: 'Collection', name: 'page' }, + type: 'oneToOne', }, { - from: { model: 'Page', name: 'snippets' }, - to: { model: 'Snippet', name: 'pages' }, + from: { model: 'CollectionPage', name: 'snippets' }, + to: { model: 'Snippet', name: 'collectionPages' }, type: 'manyToMany', }, { - from: { model: 'Page', name: 'listings' }, - to: { model: 'Listing', name: 'pages' }, - type: 'manyToMany', + from: { model: 'CollectionsPage', name: 'collections' }, + to: { model: 'Collection', name: 'collectionsPage' }, + type: 'oneToMany', + }, + { + from: { model: 'HomePage', name: 'snippets' }, + to: { model: 'Snippet', name: 'homePage' }, + type: 'oneToMany', + }, + { + from: { model: 'HomePage', name: 'collections' }, + to: { model: 'Collection', name: 'homePage' }, + type: 'oneToMany', }, ], config: { diff --git a/src/blocks/utilities/recommender.js b/src/blocks/utilities/recommender.js index ae7887c9f..75e76b7eb 100644 --- a/src/blocks/utilities/recommender.js +++ b/src/blocks/utilities/recommender.js @@ -28,7 +28,7 @@ export class Recommender { totalScoreLimit, } = Recommender.recommenderSettings; const language = snippet.language; - const primaryTag = snippet.truePrimaryTag; + const primaryTag = snippet.primaryTag; const searchTokens = snippet.searchTokensArray; const recommendationRankings = new Map(); diff --git a/src/blocks/utilities/schemer.js b/src/blocks/utilities/schemer.js index cf553be36..7f94ffb20 100644 --- a/src/blocks/utilities/schemer.js +++ b/src/blocks/utilities/schemer.js @@ -52,12 +52,14 @@ export class Schemer { }; }; - static generateListingData = ({ title, slug, items }) => { + static generateListingData = ({ title, slug, pageNumber, items }) => { + const name = pageNumber === 1 ? title : `${title} - Page ${pageNumber}`; const url = `${websiteUrl}${slug}`; + return { '@context': 'https://schema.org', '@type': 'ItemList', - name: title, + name, url, mainEntityOfPage: { '@type': 'WebPage', '@id': url }, numberOfItems: items.length, @@ -75,11 +77,6 @@ export class Schemer { '@context': 'https://schema.org', '@type': 'WebSite', url: websiteUrl, - potentialAction: { - '@type': 'SearchAction', - target: `${websiteUrl}/search?keyphrase={keyphrase}`, - 'query-input': 'required name=keyphrase', - }, }; }; } From 758b4f64f5a6df9dd3e0495829560e9b8a486cd7 Mon Sep 17 00:00:00 2001 From: Angelos Chalaris Date: Sun, 7 May 2023 12:53:43 +0300 Subject: [PATCH 25/44] Remove obsolete models and serializers --- src/blocks/models/listing.js | 232 ----------------- src/blocks/models/page.js | 239 ------------------ src/blocks/models/repository.js | 34 --- .../serializers/listingContextSerializer.js | 18 -- 4 files changed, 523 deletions(-) delete mode 100644 src/blocks/models/listing.js delete mode 100644 src/blocks/models/page.js delete mode 100644 src/blocks/models/repository.js delete mode 100644 src/blocks/serializers/listingContextSerializer.js diff --git a/src/blocks/models/listing.js b/src/blocks/models/listing.js deleted file mode 100644 index 8a029dba9..000000000 --- a/src/blocks/models/listing.js +++ /dev/null @@ -1,232 +0,0 @@ -import { uniqueElements } from 'utils'; -import tokenizeCollection from 'utils/search'; -import { Ranker } from 'blocks/utilities/ranker'; -import literals from 'lang/en'; - -const CARDS_PER_PAGE = 15; - -export const listing = { - name: 'Listing', - fields: [ - { - name: 'type', - type: 'enumRequired', - values: ['main', 'collections', 'collection'], - }, - { name: 'slugPrefix', type: 'stringRequired' }, - { name: 'relatedRecordId', type: 'string' }, - { name: 'featuredIndex', type: 'number' }, - { name: 'dataName', type: 'string' }, - { name: 'dataSplash', type: 'string' }, - { name: 'dataDescription', type: 'string' }, - { name: 'dataFeaturedListings', type: 'stringArray' }, - { name: 'isTopLevel', type: 'booleanRequired' }, - ], - properties: { - hasParent: listing => Boolean(listing.parent), - isMain: listing => listing.type === 'main', - isCollections: listing => listing.type === 'collections', - isBlog: listing => listing.id === 'collection/articles', - isBlogTag: listing => - listing.hasParent && listing.parent === 'collection/articles', - isLanguage: listing => listing.isTopLevel && !listing.isBlog, - isCollection: listing => listing.type === 'collection', - rootUrl: listing => - listing.hasParent ? listing.parent.slugPrefix : listing.slugPrefix, - siblings: listing => (listing.hasParent ? listing.parent.children : []), - siblingsExceptSelf: listing => - listing.hasParent ? listing.siblings.except(listing.id) : [], - // Used to determine the order of listings in the search index. - ranking: listing => { - if (listing.isCollections) return 0.8; - const rankingValue = Ranker.rankIndexableContent( - listing.indexableContent - ); - // Demote tag listings to promote language and curated content - if (listing.hasParent) return rankingValue * 0.25; - return rankingValue; - }, - name: listing => listing.data.name, - // This is not used the way you think. - // We use literals.tag to get the "short" name in sublinks. - // So what is this for? Listing preview cards and chips. - shortName: listing => listing.data.name, - description: listing => (listing.data ? listing.data.description : null), - shortDescription: listing => { - const shortDescription = - listing.isMain || listing.isCollections - ? null - : listing.data.shortDescription; - return shortDescription ? `

    ${shortDescription}

    ` : null; - }, - slug: listing => `${listing.slugPrefix}/p/1`, - splash: listing => (listing.data ? listing.data.splash : null), - // TODO: Probably short description here, shortId non-existent? - // TODO: Delete the pageDescription entries or something - seoDescription: listing => - literals.pageDescription(listing.type, { - snippetCount: listing.snippets.length, - listingLanguage: listing.data.language - ? listing.data.language.name - : '', - listingTag: listing.hasParent ? listing.data.shortId : '', - }), - featured: listing => (listing.isMain ? 0 : listing.data.featured || 0), - isListed: listing => { - return listing.featured > 0; - // TODO: Figure it out? - // const { type } = listing; - // if (['blog', 'main', 'collection'].includes(type)) return true; - // if (['language'].includes(type)) return listing.featured > 0; - // return false; - }, - // TODO: Needs to be updated, so searchable is part of config - isSearchable: listing => - Boolean( - listing.isListed && - !listing.isBlog && - !listing.isBlogTag && - listing.shortDescription - ), - // NOTE: This is a bit fiddly for listings without unique descriptions, - // such as tags that inherit descriptions from the language parent. Worth - // revisiting. - searchTokens: listing => { - const uniqueDescription = - listing.isCollection && listing.shortDescription - ? listing.shortDescription - : ''; - return uniqueElements( - tokenizeCollection(`${uniqueDescription} ${listing.name}`).map(v => - v.toLowerCase() - ) - ).join(' '); - }, - pageCount: listing => - Math.ceil(listing.listedSnippets.length / CARDS_PER_PAGE), - listedSnippets: listing => { - if (listing.isCollections) return listing.snippets; - if (listing.isMain) return listing.snippets.listedByPopularity; - // Catch all, also catches collection types - return listing.snippets.published; - }, - formattedSnippetCount: listing => - `${listing.listedSnippets.length} snippets`, - indexableContent: listing => - [listing.name, listing.description].join(' ').toLowerCase(), - }, - lazyProperties: { - data: - ({ models: { Collection } }) => - listing => { - if (listing.isMain) - return { - name: listing.dataName, - splash: listing.dataSplash, - description: listing.dataDescription, - }; - if (listing.isCollections) - return { - name: listing.dataName, - splash: listing.dataSplash, - description: listing.dataDescription, - }; - return Collection.records.get(listing.relatedRecordId); - }, - snippets: - ({ models: { Snippet, Listing } }) => - listing => { - if (listing.isMain) return Snippet.records; - // Abuse the snippets logic here a little bit to avoid having a new model - // just for the collections listing. - if (listing.isCollections) return Listing.records.featured; - return Snippet.records.only(...listing.data.snippets.flatPluck('id')); - }, - sublinks: - ({ models: { Listing } }) => - listing => { - if (!listing.isTopLevel && !listing.parent) return []; - if (listing.isCollections) return []; - if (listing.isMain) { - return [ - ...Listing.records.language - .sort((a, b) => b.ranking - a.ranking) - .flatMap(ls => ({ - title: ls.shortName.replace(/ Snippets$/g, ''), - url: `${ls.rootUrl}/p/1`, - selected: false, - })), - { - title: 'More collections', - url: '/collections/p/1', - selected: false, - }, - ]; - } - const links = listing.parent ? listing.siblings : listing.children; - return [ - { - title: literals.tag('all'), - url: `${listing.rootUrl}/p/1`, - selected: listing.isTopLevel, - }, - ...links - .flatMap(link => ({ - title: link.data.shortName, - url: `/${link.data.slug}/p/1`, - selected: listing.isCollection && link.id === listing.id, - })) - .sort((a, b) => a.title.localeCompare(b.title)), - ]; - }, - }, - methods: { - pageRanking: (listing, pageNumber) => { - const listingRanking = listing.ranking * 0.5; - // Promote first page, demote pages after third - const pageRanking = pageNumber === 1 ? 0.25 : pageNumber <= 3 ? 0 : -0.25; - return listingRanking + pageRanking; - }, - }, - cacheProperties: [ - 'data', - 'snippets', - 'hasParent', - 'isMain', - 'isBlog', - 'isBlogTag', - 'isLanguage', - 'isCollection', - 'rootUrl', - 'ranking', - 'name', - 'shortName', - 'description', - 'shortDescription', - 'slug', - 'splash', - 'seoDescription', - 'featured', - 'isListed', - 'isSearchable', - 'searchTokens', - 'listedSnippets', - 'formattedSnippetCount', - ], - scopes: { - main: listing => listing.isMain, - blog: listing => listing.isBlog, - language: listing => listing.isLanguage, - tag: listing => listing.hasParent, - collection: listing => listing.isCollection, - listed: listing => listing.isListed, - searchable: { - matcher: listing => listing.isSearchable, - sorter: (a, b) => b.ranking - a.ranking, - }, - featured: { - matcher: listing => listing.featuredIndex !== -1, - sorter: (a, b) => a.featuredIndex - b.featuredIndex, - }, - }, -}; diff --git a/src/blocks/models/page.js b/src/blocks/models/page.js deleted file mode 100644 index 183849fe8..000000000 --- a/src/blocks/models/page.js +++ /dev/null @@ -1,239 +0,0 @@ -import globalConfig from 'settings/global'; -import literals from 'lang/en'; -import { Schemer } from 'blocks/utilities/schemer'; -import { shuffle } from 'utils'; - -const routePrefix = globalConfig.websiteUrl; - -const NEW_BLOG_CARDS = 5; -const TOP_SNIPPET_CARDS = 5; -const TOP_COLLECTION_CHIPS = 8; - -export const page = { - name: 'Page', - fields: [ - { name: 'type', type: 'stringRequired' }, - { name: 'relatedRecordId', type: 'string' }, - { name: 'pageNumber', type: 'number' }, - { name: 'slug', type: 'string' }, - { name: 'staticPriority', type: 'number' }, - ], - properties: { - isStatic: page => page.type === 'static', - isCollectionsListing: page => page.id.startsWith('listing_collections'), - isSnippet: page => page.type === 'snippet', - isListing: page => page.type === 'listing', - isHome: page => page.id === 'home', - isUnlisted: page => (page.isSnippet ? !page.data.isListed : false), - isPublished: page => (page.isSnippet ? page.data.isPublished : true), - isIndexable: page => { - if (['404', 'search'].includes(page.id)) return false; - if (!page.isSnippet) return true; - return page.data.isPublished; - }, - priority: page => { - if (page.isSnippet) return +(page.data.ranking * 0.85).toFixed(2); - if (page.isListing) return page.data.pageRanking(page.pageNumber); - if (page.isStatic) return page.staticPriority; - return 0.3; - }, - relRoute: page => { - if (page.isSnippet) return page.data.slug; - if (page.isListing) return `${page.data.slugPrefix}/p/${page.pageNumber}`; - if (page.isStatic) return page.slug; - return ''; - }, - fullRoute: page => `${routePrefix}${page.relRoute}`, - }, - lazyProperties: { - data: - ({ models: { Snippet, Listing } }) => - page => { - if (page.isSnippet) return Snippet.records.get(page.relatedRecordId); - if (page.isListing) return Listing.records.get(page.relatedRecordId); - return {}; - }, - context: - ({ - models: { Snippet, Listing }, - serializers: { - SnippetContextSerializer, - ListingContextSerializer, - SearchResultSerializer, - PreviewSerializer, - }, - }) => - page => { - let context = {}; - if (page.isHome) { - const listedCollections = Listing.records.featured - .toArray() - .slice(0, TOP_COLLECTION_CHIPS); - const newBlogs = Snippet.records.blogs.listedByNew - .toArray() - .slice(0, NEW_BLOG_CARDS); - const topSnippets = shuffle( - Snippet.records.snippets.listedByPopularity - .toArray() - .slice(0, TOP_SNIPPET_CARDS * 5) - ).slice(0, TOP_SNIPPET_CARDS); - // TODO: These are damn chips, I need to make it simpler - context.featuredCollections = PreviewSerializer.serializeArray( - listedCollections, - { type: 'collection' } - ); - context.featuredCollections.push({ - title: 'More collections', - url: '/collections/p/1', - selected: false, - }); - context.featuredSnippets = PreviewSerializer.serializeArray( - [...newBlogs, ...topSnippets], - { type: 'snippet' } - ); - // TODO: Move this to a better place - context.splashImage = '/assets/splash/work-sunrise.png'; - context.snippetListUrl = '/list/p/1'; - context.pageDescription = literals.pageDescription('main', { - snippetCount: Snippet.records.published.length, - }); - context.structuredData = Schemer.generateHomeData(); - } - if (page.isSnippet) { - context.breadcrumbs = page.data.breadcrumbs; - context.pageDescription = page.data.seoDescription; - context.snippet = SnippetContextSerializer.serialize(page.data); - context.structuredData = Schemer.generateSnippetData({ - title: page.data.seoTitle, - slug: page.relRoute, - description: context.snippet.description, - cover: context.snippet.cover, - dateModified: page.data.dateModified, - author: page.data.author, - }); - - let recommendedItems = PreviewSerializer.serializeArray( - page.data.recommendedSnippets.toArray(), - { type: 'snippet' } - ); - if (page.data.recommendedCollection) - recommendedItems.unshift( - PreviewSerializer.serialize( - page.data.recommendedCollection.listing, - { type: 'collection' } - ) - ); - context.recommendations = recommendedItems; - } - if (page.isListing) { - context.slug = page.relRoute; - const pageNumber = page.pageNumber; - const totalPages = page.data.pageCount; - const baseUrl = page.data.slugPrefix; - let buttons = - totalPages === 2 - ? [1, 2] - : [ - 1, - Math.min(Math.max(pageNumber, 2), totalPages - 1), - totalPages, - ]; - context.paginator = - totalPages > 1 - ? { - previous: - pageNumber > 1 - ? { - url: `${baseUrl}/p/${pageNumber - 1}`, - label: 'Previous', - } - : null, - pages: buttons.map(buttonNumber => ({ - label: buttonNumber, - url: `${baseUrl}/p/${buttonNumber}`, - current: buttonNumber === pageNumber, - })), - next: - pageNumber < totalPages - ? { - url: `${baseUrl}/p/${pageNumber + 1}`, - label: 'Next', - } - : null, - } - : null; - Object.entries(ListingContextSerializer.serialize(page.data)).forEach( - ([key, value]) => { - context[key] = value; - } - ); - if (page.isCollectionsListing) { - context.snippetList = PreviewSerializer.serializeArray( - page.listings.toArray(), - { type: 'collection' } - ); - } else { - context.snippetList = PreviewSerializer.serializeArray( - page.snippets.toArray(), - { type: 'snippet' } - ); - } - context.structuredData = Schemer.generateListingData({ - title: - pageNumber === 1 - ? context.listingName - : `${context.listingName} - Page ${pageNumber}`, - slug: page.relRoute, - items: context.snippetList, - }); - } - if (page.type === 'search') { - const sortedSnippets = Snippet.records.listedByPopularity; - context.searchIndex = [ - ...SearchResultSerializer.serializeArray( - Listing.records.searchable.toArray(), - { type: 'collection' } - ), - ...SearchResultSerializer.serializeArray(sortedSnippets.toArray(), { - type: 'snippet', - }), - ]; - } - return context; - }, - }, - cacheProperties: [ - 'data', - 'context', - 'relRoute', - 'fullRoute', - 'priority', - 'isUnlisted', - 'isPublished', - 'isIndexable', - 'isCollectionsListing', - 'isStatic', - 'isSnippet', - 'isListing', - 'isHome', - ], - scopes: { - listed: page => !page.isUnlisted && page.id !== '404', - indexable: { - matcher: page => page.isIndexable, - sorter: (a, b) => b.priority - a.priority, - }, - published: page => page.isPublished, - feedEligible: { - matcher: page => page.type === 'snippet' && !page.isUnlisted, - sorter: (a, b) => b.data.dateModified - a.data.dateModified, - }, - snippets: page => page.isSnippet, - // Exclude collections listing to avoid path conflicts in Next.js - listing: page => page.isListing, - static: page => page.isStatic, - home: page => page.isHome, - search: page => page.id === 'search', - collections: page => page.isCollectionsListing, - }, -}; diff --git a/src/blocks/models/repository.js b/src/blocks/models/repository.js deleted file mode 100644 index f238003dd..000000000 --- a/src/blocks/models/repository.js +++ /dev/null @@ -1,34 +0,0 @@ -export const repository = { - name: 'Repository', - fields: [ - { name: 'name', type: 'stringRequired' }, - { name: 'repoUrl', type: 'stringRequired' }, - { name: 'slug', type: 'stringRequired' }, - { name: 'isBlog', type: 'booleanRequired', defaultValue: false }, - { name: 'featured', type: 'booleanRequired' }, - { name: 'splash', type: 'stringRequired' }, - { name: 'description', type: 'stringRequired' }, - { name: 'shortDescription', type: 'stringRequired' }, - ], - properties: { - slugPrefix: repo => `${repo.slug}/s`, - repoUrlPrefix: repo => `${repo.repoUrl}/blob/master/snippets`, - isCSS: repo => repo.id === '30css', - isReact: repo => repo.id === '30react', - }, - lazyProperties: { - listing: - ({ models: { Listing } }) => - repo => { - const type = repo.isBlog ? 'blog' : 'language'; - const listingId = `${type}/${repo.slug}`; - return Listing.records.get(listingId); - }, - }, - cacheProperties: ['isCSS', 'isReact', 'listing'], - scopes: { - css: repo => repo.isCSS, - react: repo => repo.isReact, - blog: repo => repo.isBlog, - }, -}; diff --git a/src/blocks/serializers/listingContextSerializer.js b/src/blocks/serializers/listingContextSerializer.js deleted file mode 100644 index ce9f1572b..000000000 --- a/src/blocks/serializers/listingContextSerializer.js +++ /dev/null @@ -1,18 +0,0 @@ -import pathSettings from 'settings/paths'; - -export const listingContextSerializer = { - name: 'ListingContextSerializer', - methods: { - splashUrl: listing => - listing.splash - ? `/${pathSettings.staticAssetPath}/splash/${listing.splash}` - : `/${pathSettings.staticAssetPath}/splash/laptop-view.png`, - }, - attributes: [ - ['name', 'listingName'], - ['description', 'listingDescription'], - ['splashUrl', 'listingCover'], - ['sublinks', 'listingSublinks'], - ['seoDescription', 'pageDescription'], - ], -}; From ca541bcaf608111eab041a47e98f70733a1e1332 Mon Sep 17 00:00:00 2001 From: Angelos Chalaris Date: Sun, 7 May 2023 12:53:58 +0300 Subject: [PATCH 26/44] Match serializers to new models --- src/blocks/serializers/pageSerializer.js | 15 ++------------- src/blocks/serializers/previewSerializer.js | 11 +++-------- src/blocks/serializers/searchResultSerializer.js | 10 ++++------ 3 files changed, 9 insertions(+), 27 deletions(-) diff --git a/src/blocks/serializers/pageSerializer.js b/src/blocks/serializers/pageSerializer.js index 8e53c46e2..9c1511cd1 100644 --- a/src/blocks/serializers/pageSerializer.js +++ b/src/blocks/serializers/pageSerializer.js @@ -3,19 +3,8 @@ export const pageSerializer = { methods: { params: (page, { withParams } = {}) => { if (!withParams) return undefined; - if (page.isSnippet) { - const segments = page.relRoute.slice(1).split('/'); - return { - lang: segments[0], - snippet: segments.slice(-1)[0], - }; - } - if (page.isListing) { - const [lang, ...listing] = page.relRoute.slice(1).split('/'); - return { lang, listing }; - } - return {}; + return page.params; }, }, - attributes: [['context', 'props'], 'params'], + attributes: ['props', 'params'], }; diff --git a/src/blocks/serializers/previewSerializer.js b/src/blocks/serializers/previewSerializer.js index 479dddf86..92294d504 100644 --- a/src/blocks/serializers/previewSerializer.js +++ b/src/blocks/serializers/previewSerializer.js @@ -20,17 +20,12 @@ export const previewSerializer = { ? `/${pathSettings.staticAssetPath}/splash/${item.splash}` : `/${pathSettings.staticAssetPath}/splash/laptop-view.png`; }, + url: (item, { type }) => + type === 'snippet' ? item.slug : item.firstPageSlug, tags: (item, { type }) => type === 'snippet' ? item.formattedPreviewTags : 'Collection', extraContext: (item, { type }) => type === 'snippet' ? item.dateFormatted : item.formattedSnippetCount, }, - attributes: [ - 'title', - 'description', - ['slug', 'url'], - 'cover', - 'tags', - 'extraContext', - ], + attributes: ['title', 'description', 'url', 'cover', 'tags', 'extraContext'], }; diff --git a/src/blocks/serializers/searchResultSerializer.js b/src/blocks/serializers/searchResultSerializer.js index ea9d73275..4d51a2d8e 100644 --- a/src/blocks/serializers/searchResultSerializer.js +++ b/src/blocks/serializers/searchResultSerializer.js @@ -1,17 +1,15 @@ export const searchResultSerializer = { name: 'SearchResultSerializer', methods: { - // TODO: This is quite a dirty hack to keep things consistent as before, but - // it needs a reiteration. title: (item, { type }) => - type === 'snippet' - ? item.shortTitle - : item.shortName.replace(/ Snippets$/g, ''), + type === 'snippet' ? item.shortTitle : item.shortName, + url: (item, { type }) => + type === 'snippet' ? item.slug : item.firstPageSlug, tag: (item, { type }) => type === 'snippet' ? item.formattedMiniPreviewTag : item.formattedSnippetCount, type: (item, { type }) => type, }, - attributes: ['title', ['slug', 'url'], 'tag', 'searchTokens', 'type'], + attributes: ['title', 'url', 'tag', 'searchTokens', 'type'], }; From dd33c3dd3964fc8605daf78a55f4ef08fc505dac Mon Sep 17 00:00:00 2001 From: Angelos Chalaris Date: Sun, 7 May 2023 12:54:06 +0300 Subject: [PATCH 27/44] Update SearchIndexWriter --- src/blocks/writers/searchIndexWriter.js | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/src/blocks/writers/searchIndexWriter.js b/src/blocks/writers/searchIndexWriter.js index e8d6c48c6..014b9b889 100644 --- a/src/blocks/writers/searchIndexWriter.js +++ b/src/blocks/writers/searchIndexWriter.js @@ -16,8 +16,26 @@ export class SearchIndexWriter { static async write() { const logger = new Logger('SearchIndexWriter.write'); - let searchIndex = Application.dataset.getModel('Page').records.search.first - .context.searchIndex; + let SearchResultSerializer = Application.dataset.getSerializer( + 'SearchResultSerializer' + ); + + let snippets = Application.dataset.getModel('Snippet').records.listed; + if (process.env.NODE_ENV === 'production') snippets = snippets.published; + + const snippetsData = SearchResultSerializer.serializeArray( + snippets.toArray(), + { type: 'snippet' } + ); + + let collections = Application.dataset.getModel('Collection').records.listed; + + const collectionsData = SearchResultSerializer.serializeArray( + collections.toArray(), + { type: 'collection' } + ); + + let searchIndex = [...snippetsData, ...collectionsData]; logger.log(`Writing search index for ${searchIndex.length} items`); await JSONHandler.toFile(outPath, { searchIndex }); From 5e5a8094f8943d35932fabef9db9005f3ffca318 Mon Sep 17 00:00:00 2001 From: Angelos Chalaris Date: Sun, 7 May 2023 12:54:13 +0300 Subject: [PATCH 28/44] Update PageWriter --- src/blocks/writers/pageWriter.js | 59 +++++++++++++++++++------------- 1 file changed, 36 insertions(+), 23 deletions(-) diff --git a/src/blocks/writers/pageWriter.js b/src/blocks/writers/pageWriter.js index f98f24a8a..b4456cec6 100644 --- a/src/blocks/writers/pageWriter.js +++ b/src/blocks/writers/pageWriter.js @@ -17,38 +17,51 @@ export class PageWriter { const logger = new Logger('PageWriter.write'); let PageSerializer = Application.dataset.getSerializer('PageSerializer'); - let pages = Application.dataset.getModel('Page').records; - if (process.env.NODE_ENV === 'production') pages = pages.published; - logger.log(`Generating JSON files for ${pages.length} pages`); - - const staticData = PageSerializer.serializeRecordSet( - pages.static, - {}, - (key, value) => value.relRoute.slice(1) + + let snippetPages = Application.dataset.getModel('SnippetPage').records; + if (process.env.NODE_ENV === 'production') + snippetPages = snippetPages.published; + const snippetData = PageSerializer.serializeRecordSet( + snippetPages, + { withParams: true }, + key => `${key.split('$')[1]}` ); - const listingData = PageSerializer.serializeRecordSet( - pages.listing, + + let collectionPages = + Application.dataset.getModel('CollectionPage').records; + const collectionData = PageSerializer.serializeRecordSet( + collectionPages, { withParams: true }, - (key, value) => value.relRoute.slice(1) + key => `${key.split('$')[1]}` ); - const snippetData = PageSerializer.serializeRecordSet( - pages.snippets, + + let collectionsPages = + Application.dataset.getModel('CollectionsPage').records; + + const collectionsData = PageSerializer.serializeRecordSet( + collectionsPages, { withParams: true }, - key => `${key.split('_')[1]}` + key => `${key.split('$')[1]}` ); + const homePage = Application.dataset.getModel('HomePage').records.first; + const homeData = PageSerializer.serialize(homePage); + + const totalPages = + snippetPages.length + + collectionPages.length + + collectionsPages.length + + 1; + logger.log(`Generating JSON files for ${totalPages} pages`); + return Promise.all([ // Home page - JSONHandler.toFile( - `${outPath}/index.json`, - PageSerializer.serialize(pages.home.first) - ), - // Static pages - ...Object.entries(staticData).map(([fileName, page]) => { - return JSONHandler.toFile(`${outPath}/${fileName}.json`, page); + JSONHandler.toFile(`${outPath}/index.json`, { ...homeData }), + // Collection pages + JSONHandler.toFile(`${outPath}/[lang]/[...listing].json`, { + ...collectionData, + ...collectionsData, }), - // Listing pages - JSONHandler.toFile(`${outPath}/[lang]/[...listing].json`, listingData), // Snippet pages JSONHandler.toFile(`${outPath}/[lang]/s/[snippet].json`, snippetData), ]).then(() => { From b4c7cf30ca850273dc9949252e8bb30c4512b595 Mon Sep 17 00:00:00 2001 From: Angelos Chalaris Date: Sun, 7 May 2023 12:54:29 +0300 Subject: [PATCH 29/44] Update Extractor for new application schema --- src/blocks/extractor/index.js | 312 ++++++++++++++-------------------- 1 file changed, 132 insertions(+), 180 deletions(-) diff --git a/src/blocks/extractor/index.js b/src/blocks/extractor/index.js index 68b811be1..df66643ea 100644 --- a/src/blocks/extractor/index.js +++ b/src/blocks/extractor/index.js @@ -7,13 +7,7 @@ import { TextParser } from 'blocks/extractor/textParser'; import { MarkdownParser } from 'blocks/extractor/markdownParser'; import { JSONHandler } from 'blocks/utilities/jsonHandler'; import { YAMLHandler } from 'blocks/utilities/yamlHandler'; -import { - convertToSeoSlug, - uniqueElements, - stripMarkdownFormat, - capitalize, -} from 'utils'; -import literals from 'lang/en'; +import { stripMarkdownFormat } from 'utils'; const mdCodeFence = '```'; const codeMatcher = new RegExp( @@ -26,41 +20,13 @@ export class Extractor { if (process.env.NODE_ENV === 'production') await Content.update(); const { rawContentPath: contentDir } = pathSettings; const languageData = Extractor.extractLanguageData(contentDir); - const contentConfigs = Extractor.extractContentConfigs( - contentDir, - languageData - ); const collectionConfigs = Extractor.extractCollectionConfigs(contentDir); const authors = Extractor.extractAuthors(contentDir); - const snippets = await Extractor.extractSnippets( - contentDir, - contentConfigs, - [...languageData.values()] - ); - const { mainListing, collectionListing } = - Extractor.extractHubConfig(contentDir); + const snippets = await Extractor.extractSnippets(contentDir, languageData); + const collectionsHubConfig = + Extractor.extractCollectionsHubConfig(contentDir); // Language data not passed here by design, pass only if needed const data = { - repositories: contentConfigs.map(config => { - // Exclude specific keys - const { - dirName, - tagIcons, - slugPrefix, - language: rawLanguage, - tagMetadata, - references, - ...rest - } = config; - const language = - rawLanguage && rawLanguage.long - ? rawLanguage.long.toLowerCase() - : null; - return { - ...rest, - language, - }; - }), collections: collectionConfigs, snippets, authors, @@ -68,38 +34,12 @@ export class Extractor { const { references, ...restData } = data; return { ...restData }; }), - collectionListingConfig: Object.entries(collectionListing).reduce( - (acc, [key, value]) => ({ ...acc, [`data${capitalize(key)}`]: value }), - {} - ), - mainListingConfig: Object.entries(mainListing).reduce( - (acc, [key, value]) => ({ ...acc, [`data${capitalize(key)}`]: value }), - {} - ), + collectionsHub: collectionsHubConfig, }; await Extractor.writeData(data); return data; }; - static extractContentConfigs = (contentDir, languageData) => { - const logger = new Logger('Extractor.extractContentConfigs'); - logger.log('Extracting content configurations'); - const configs = YAMLHandler.fromGlob( - `${contentDir}/configs/repos/*.yaml` - ).map(config => { - const language = languageData.get(config.language) || {}; - - return { - ...config, - language, - id: `${config.dirName}`, - slugPrefix: `${config.slug}/s`, - }; - }); - logger.success('Finished extracting content configurations'); - return configs; - }; - static extractCollectionConfigs = contentDir => { const logger = new Logger('Extractor.extractCollectionConfigs'); logger.log('Extracting collection configurations'); @@ -109,21 +49,32 @@ export class Extractor { ).map(([path, config]) => { const { snippetIds = [], + slug: id, name, shortName = name, + miniName = shortName, + shortDescription, topLevel = false, ...rest } = config; - const id = path - .replace(`${contentDir}/configs/collections/`, '') - .split('.')[0]; + const slug = `/${id}`; + const seoDescription = stripMarkdownFormat(shortDescription); + + if (seoDescription.length > 140) { + logger.warn(`Collection ${id} has a long SEO description.`); + } + return { + id, name, + slug, shortName, + miniName, topLevel, + shortDescription, + seoDescription, ...rest, snippetIds, - id, }; }); logger.success('Finished extracting collection configurations'); @@ -165,143 +116,144 @@ export class Extractor { return languageData; }; - static extractSnippets = async (contentDir, contentConfigs, languageData) => { + static extractSnippets = async (contentDir, languageData) => { const logger = new Logger('Extractor.extractSnippets'); logger.log('Extracting snippets'); - MarkdownParser.loadLanguageData(languageData); + const snippetsGlob = `${contentDir}/sources/30code/**/s/*.md`; + + MarkdownParser.loadLanguageData([...languageData.values()]); let snippets = []; - await Promise.all( - contentConfigs.map(config => { - const isBlog = config.isBlog; - const isCSS = config.id === '30css'; - const isReact = config.id === '30react'; - const snippetsPath = `${contentDir}/sources/${config.dirName}/snippets`; + await TextParser.fromGlob(snippetsGlob).then(snippetData => { + const parsedData = snippetData.map(snippet => { + const { + filePath, + fileName, + title, + shortTitle = title, + tags: rawTags, + type = 'snippet', + language: languageKey, + excerpt, + cover, + author, + dateModified, + body, + unlisted, + } = snippet; + + // This check might be overkill, but better safe than sorry + const isBlog = type !== 'snippet' && filePath.includes('/articles/'); + const isCSS = filePath.includes('/css/'); + const isReact = filePath.includes('/react/'); + + const language = languageData.get(languageKey) || undefined; const languageKeys = isBlog ? [] : isCSS ? ['js', 'html', 'css'] : isReact ? ['js', 'jsx'] - : [config.language.short]; + : [language]; - return TextParser.fromDir(snippetsPath).then(snippetData => { - const parsedData = snippetData.map(snippet => { - const { - fileName, - title, - shortTitle = title, - tags: rawTags, - type = 'snippet', - excerpt, - cover, - author, - dateModified, - body, - unlisted, - } = snippet; + const id = filePath + .replace(`${contentDir}/sources/30code/`, '') + .slice(0, -3); + const tags = rawTags.map(tag => tag.toLowerCase()); - const id = `${config.slugPrefix}${convertToSeoSlug( - fileName.slice(0, -3) - )}`; - const tags = rawTags.map(tag => tag.toLowerCase()); + const bodyText = body + .slice(0, body.indexOf(mdCodeFence)) + .replace(/\r\n/g, '\n'); + const isLongBlog = isBlog && bodyText.indexOf('\n\n') > 180; + const shortSliceIndex = isLongBlog + ? bodyText.indexOf(' ', 160) + : bodyText.indexOf('\n\n'); + const shortText = + excerpt && excerpt.trim().length !== 0 + ? excerpt + : `${bodyText.slice(0, shortSliceIndex)}${isLongBlog ? '...' : ''}`; - const bodyText = body - .slice(0, body.indexOf(mdCodeFence)) - .replace(/\r\n/g, '\n'); - const isLongBlog = isBlog && bodyText.indexOf('\n\n') > 180; - const shortSliceIndex = isLongBlog - ? bodyText.indexOf(' ', 160) - : bodyText.indexOf('\n\n'); - const shortText = - excerpt && excerpt.trim().length !== 0 - ? excerpt - : `${bodyText.slice(0, shortSliceIndex)}${ - isLongBlog ? '...' : '' - }`; + const fullText = body; + const seoDescription = stripMarkdownFormat(shortText); - const fullText = body; - const seoDescription = stripMarkdownFormat(shortText); + if (seoDescription.length > 140) { + logger.warn(`Snippet ${id} has a long SEO description.`); + } - if (seoDescription.length > 140 && unlisted !== true) { - logger.warn(`Snippet ${id} has a long SEO description.`); - } + let code = null; - let code = null; + if (isCSS || isReact) { + const codeBlocks = [...body.matchAll(codeMatcher)].map(v => + v.groups.code.trim() + ); - if (isCSS || isReact) { - const codeBlocks = [...body.matchAll(codeMatcher)].map(v => - v.groups.code.trim() - ); + if (isCSS) { + code = { + html: codeBlocks[0], + css: codeBlocks[1], + js: codeBlocks[2] || '', + }; + } - if (isCSS) { - code = { - html: codeBlocks[0], - css: codeBlocks[1], - js: codeBlocks[2] || '', - }; - } + if (isReact) { + code = + codeBlocks.length > 2 + ? { + js: `${codeBlocks[1]}\n\n${codeBlocks[2]}`, + css: codeBlocks[0], + } + : { + js: `${codeBlocks[0]}\n\n${codeBlocks[1]}`, + css: '', + }; + /* eslint-disable camelcase */ + code = { + ...code, + html: JSX_SNIPPET_PRESETS.envHtml, + js_pre_processor: JSX_SNIPPET_PRESETS.jsPreProcessor, + js_external: JSX_SNIPPET_PRESETS.jsImports.join(';'), + }; + /* eslint-enable camelcase */ + } + } - if (isReact) { - code = - codeBlocks.length > 2 - ? { - js: `${codeBlocks[1]}\n\n${codeBlocks[2]}`, - css: codeBlocks[0], - } - : { - js: `${codeBlocks[0]}\n\n${codeBlocks[1]}`, - css: '', - }; - /* eslint-disable camelcase */ - code = { - ...code, - html: JSX_SNIPPET_PRESETS.envHtml, - js_pre_processor: JSX_SNIPPET_PRESETS.jsPreProcessor, - js_external: JSX_SNIPPET_PRESETS.jsImports.join(';'), - }; - /* eslint-enable camelcase */ - } - } + const html = MarkdownParser.parseSegments( + { + fullDescription: fullText, + description: shortText, + }, + languageKeys + ); - const html = MarkdownParser.parseSegments( - { - fullDescription: fullText, - description: shortText, - }, - languageKeys - ); + return { + id, + fileName, + title, + shortTitle, + tags, + dateModified, + listed: unlisted === true ? false : true, + type, + shortText, + fullText, + ...html, + code, + cover, + author, + seoDescription, + language: languageKey, + }; + }); - return { - id, - fileName, - title, - shortTitle, - tags, - dateModified, - listed: unlisted === true ? false : true, - type, - shortText, - fullText, - ...html, - code, - cover, - author, - seoDescription, - repository: config.id, - }; - }); - snippets.push(...parsedData); - }); - }) - ); + snippets = parsedData; + }); logger.success('Finished extracting snippets'); return snippets; }; - static extractHubConfig = contentDir => { - const logger = new Logger('Extractor.extractHubConfig'); + static extractCollectionsHubConfig = contentDir => { + const logger = new Logger('Extractor.extractCollectionsHubConfig'); logger.log('Extracting hub pages configuration'); const hubConfig = YAMLHandler.fromFile(`${contentDir}/configs/hub.yaml`); logger.log('Finished extracting hub pages configuration'); From e739fa6d269512114d12b4cc361f4521ad1488c3 Mon Sep 17 00:00:00 2001 From: Angelos Chalaris Date: Sun, 7 May 2023 12:54:44 +0300 Subject: [PATCH 30/44] Deprecate language-specific literals --- src/lang/en/index.js | 56 -------------------------------------------- 1 file changed, 56 deletions(-) delete mode 100644 src/lang/en/index.js diff --git a/src/lang/en/index.js b/src/lang/en/index.js deleted file mode 100644 index 1f224d5a5..000000000 --- a/src/lang/en/index.js +++ /dev/null @@ -1,56 +0,0 @@ -import settings from 'settings/global'; -import tagSettings from 'settings/tags'; -import { capitalize } from 'utils'; - -const { specialTagsDictionary } = tagSettings; - -const formatTag = tag => { - if (!tag.length) return ''; - if (specialTagsDictionary[tag]) return specialTagsDictionary[tag]; - return capitalize(tag); -}; - -/* istanbul ignore next */ -const literals = { - tag: t => `${formatTag(t)}`, - shortCodelang: l => `${l}`, - shortCodelangTag: (l, t) => `${l} ${formatTag(t)}`, - shortBlogTag: t => `${formatTag(t)}`, - codelang: l => `${l} Snippets`, - codelangTag: (l, t) => `${l} ${formatTag(t)} Snippets`, - blogTag: t => `${formatTag(t)} Articles`, - pageDescription: (t, p) => { - switch (t) { - case 'language': - return `Browse ${p.snippetCount} ${p.listingLanguage} code snippets for all your development needs on ${settings.websiteName}.`; - case 'tag': - return p.listingLanguage - ? `Browse ${p.snippetCount} ${p.listingLanguage} ${formatTag( - p.listingTag - )} code snippets for all your development needs on ${ - settings.websiteName - }.` - : `Browse ${p.snippetCount} ${formatTag( - p.listingTag - )} articles for all your development needs on ${ - settings.websiteName - }.`; - case 'blog': - return `Browse ${p.snippetCount} code articles for all your development needs on ${settings.websiteName}.`; - case 'main': - return `Browse ${ - p.snippetCount - } ${settings.websiteDescription.toLowerCase()} on ${ - settings.websiteName - }.`; - case 'collections': - return `Browse ${p.snippetCount} snippet collections on ${settings.websiteName}.`; - default: - return `Find ${settings.websiteDescription.toLowerCase()} on ${ - settings.websiteName - }.`; - } - }, -}; - -export default literals; From e701737e72fe08b628450b264dfa64b6f3f425e5 Mon Sep 17 00:00:00 2001 From: Angelos Chalaris Date: Sun, 7 May 2023 12:55:02 +0300 Subject: [PATCH 31/44] Remove useless & deprecated specs --- src/test/blocks/schema.test.js | 1089 --------------------- src/test/blocks/utilities/content.test.js | 53 - 2 files changed, 1142 deletions(-) delete mode 100644 src/test/blocks/schema.test.js delete mode 100644 src/test/blocks/utilities/content.test.js diff --git a/src/test/blocks/schema.test.js b/src/test/blocks/schema.test.js deleted file mode 100644 index 58e90a563..000000000 --- a/src/test/blocks/schema.test.js +++ /dev/null @@ -1,1089 +0,0 @@ -// Slightly misleading name, this is a test for the schema and its models - -// Import and mute logger before doing anything else -import { Logger } from 'blocks/utilities/logger'; -Logger.muteGlbobal = true; - -import { Application } from 'blocks/application'; -import { content } from 'test/fixtures/content'; - -// Keep this outside of test scope, somehow it gets reset even in beforeAll -Application.initialize(content); - -describe('Application/Schema', () => { - it('dataset has the correct models', () => { - expect(Application.modelNames.sort()).toEqual([ - 'Author', - 'Collection', - 'Language', - 'Listing', - 'Page', - 'Repository', - 'Snippet', - 'Tag', - ]); - }); - - // No tests for Author, the model doesn't have any logic whatsoever - - describe('Repository', () => { - const Repository = Application.dataset.getModel('Repository'); - - describe('property: slugPrefix', () => { - it('returns the correct value', () => { - const repo = Repository.records.get('30css'); - expect(repo.slugPrefix).toEqual('css/s'); - }); - }); - - describe('property: repoUrlPrefix', () => { - it('returns the correct value', () => { - const repo = Repository.records.get('30css'); - expect(repo.repoUrlPrefix).toEqual( - 'https://github.com/30-seconds/30-seconds-of-css/blob/master/snippets' - ); - }); - }); - - describe('property: isCSS', () => { - it('returns true for css repositories', () => { - const repo = Repository.records.get('30css'); - expect(repo.isCSS).toEqual(true); - }); - - it('returns false for non-css repositories', () => { - const repo = Repository.records.get('30react'); - expect(repo.isCSS).toEqual(false); - }); - }); - - describe('property: isReact', () => { - it('returns true for react repositories', () => { - const repo = Repository.records.get('30react'); - expect(repo.isReact).toEqual(true); - }); - - it('returns false for non-react repositories', () => { - const repo = Repository.records.get('30css'); - expect(repo.isReact).toEqual(false); - }); - }); - - describe('property: listing', () => { - const repo = Repository.records.get('30css'); - expect(repo.listing.id).toEqual('language/css'); - }); - - describe('scope: css', () => { - it('returns only css repositories', () => { - const cssRepos = Repository.records.css; - expect(cssRepos.length).toEqual(1); - expect(cssRepos.has('30css')).toBe(true); - }); - }); - - describe('scope: react', () => { - it('returns only react repositories', () => { - const reactRepos = Repository.records.react; - expect(reactRepos.length).toEqual(1); - expect(reactRepos.has('30react')).toBe(true); - }); - }); - - describe('scope: blog', () => { - it('returns only blog repositories', () => { - const blogRepos = Repository.records.blog; - expect(blogRepos.length).toEqual(1); - expect(blogRepos.has('30blog')).toBe(true); - }); - }); - }); - - describe('Collection', () => { - const Collection = Application.dataset.getModel('Collection'); - - describe('property: listing', () => { - it('returns the listing', () => { - const collection = Collection.records.get('react-rendering'); - expect(collection.listing.id).toEqual('collection/c/react-rendering'); - }); - }); - }); - - describe('Language', () => { - const Language = Application.dataset.getModel('Language'); - - describe('scope: full', () => { - it('returns all languages except HTML', () => { - const fullLanguages = Language.records.full; - expect(fullLanguages.length).toBe(content.languages.length - 1); - expect(fullLanguages.get('html')).toBeUndefined(); - }); - }); - }); - - describe('Tag', () => { - const Tag = Application.dataset.getModel('Tag'); - - describe('property: shortId', () => { - it('returns the short id', () => { - const tag = Tag.records.get('30react_hooks'); - expect(tag.shortId).toEqual('hooks'); - }); - }); - - describe('property: isBlogTag', () => { - it('returns true for blog tags', () => { - const tag = Tag.records.get('30blog_css'); - expect(tag.isBlogTag).toBe(true); - }); - - it('returns false for non-blog tags', () => { - const tag = Tag.records.get('30react_hooks'); - expect(tag.isBlogTag).toBe(false); - }); - }); - - describe('property: language', () => { - it('returns the language', () => { - const Language = Application.dataset.getModel('Language'); - const tag = Tag.records.get('30react_hooks'); - expect(tag.language).toEqual(Language.records.get('react')); - }); - }); - - describe('property: featured', () => { - it('returns the featured value of the repository', () => { - const tag = Tag.records.get('30react_hooks'); - expect(tag.featured).toBe(true); - }); - }); - - describe('property: listing', () => { - it('returns the listing', () => { - const tag = Tag.records.get('30react_hooks'); - expect(tag.listing.id).toEqual('tag/react/t/hooks'); - }); - }); - }); - - describe('Snippet', () => { - const Snippet = Application.dataset.getModel('Snippet'); - - describe('property: primaryTag', () => { - it('returns the primary tag', () => { - const snippet = Snippet.records.get('js/s/format-duration'); - const blogSnippet = Snippet.records.get('articles/s/js-callbacks'); - - expect(snippet.primaryTag).toEqual('date'); - expect(blogSnippet.primaryTag).toEqual('javascript'); - }); - }); - - describe('property: truePrimaryTag', () => { - it('returns the true primary tag', () => { - const snippet = Snippet.records.get('js/s/format-duration'); - const blogSnippet = Snippet.records.get('articles/s/js-callbacks'); - - expect(snippet.truePrimaryTag).toEqual('date'); - expect(blogSnippet.truePrimaryTag).toEqual('function'); - }); - }); - - describe('property: formattedPrimaryTag', () => { - it('returns the formatted primary tag', () => { - const snippet = Snippet.records.get('js/s/format-duration'); - expect(snippet.formattedPrimaryTag).toEqual('Date'); - }); - }); - - describe('property: formattedMiniPreviewTag', () => { - it('returns the formatted language for regular snippets', () => { - const snippet = Snippet.records.get('js/s/format-duration'); - expect(snippet.formattedMiniPreviewTag).toEqual('JavaScript'); - }); - - it('returns "Article" for blog snippets without language', () => { - const snippet = Snippet.records.get( - 'articles/s/10-vs-code-extensions-for-js-developers' - ); - expect(snippet.formattedMiniPreviewTag).toEqual('Article'); - }); - }); - - describe('property: formattedTags', () => { - it('returns the formatted tags', () => { - const snippet = Snippet.records.get('js/s/format-duration'); - expect(snippet.formattedTags).toEqual( - ['JavaScript', 'Date', 'Math', 'String'].join(', ') - ); - }); - }); - - describe('property: formattedPreviewTags', () => { - it('returns the formatted preview tags', () => { - const snippet = Snippet.records.get('js/s/format-duration'); - expect(snippet.formattedPreviewTags).toEqual( - ['JavaScript', 'Date'].join(', ') - ); - }); - }); - - describe('property: isBlog', () => { - it('returns true if the snippet is a blog snippet', () => { - const snippet = Snippet.records.get('articles/s/js-callbacks'); - expect(snippet.isBlog).toBe(true); - }); - - it('returns false if the snippet is not a blog snippet', () => { - const snippet = Snippet.records.get('js/s/format-duration'); - expect(snippet.isBlog).toBe(false); - }); - }); - - describe('property: isCSS', () => { - it('returns true if the snippet is a CSS snippet', () => { - const snippet = Snippet.records.get('css/s/triangle'); - expect(snippet.isCSS).toBe(true); - }); - - it('returns false if the snippet is not a CSS snippet', () => { - const snippet = Snippet.records.get('js/s/format-duration'); - expect(snippet.isCSS).toBe(false); - }); - }); - - describe('property: isReact', () => { - it('returns true if the snippet is a React snippet', () => { - const snippet = Snippet.records.get('react/s/use-interval'); - expect(snippet.isReact).toBe(true); - }); - - it('returns false if the snippet is not a React snippet', () => { - const snippet = Snippet.records.get('js/s/format-duration'); - expect(snippet.isReact).toBe(false); - }); - }); - - describe('property: slug', () => { - it('returns the slug', () => { - const snippet = Snippet.records.get('js/s/format-duration'); - expect(snippet.slug).toEqual('/js/s/format-duration'); - }); - }); - - describe('property: fileSlug', () => { - it('returns the file slug', () => { - const snippet = Snippet.records.get('js/s/format-duration'); - expect(snippet.fileSlug).toEqual('/format-duration'); - }); - }); - - describe('property: url', () => { - it('returns the url', () => { - const snippet = Snippet.records.get('js/s/format-duration'); - expect(snippet.url).toEqual( - 'https://github.com/30-seconds/30-seconds-of-code/blob/master/snippets/formatDuration.md' - ); - }); - }); - - describe('property: actionType', () => { - it('returns the action type', () => { - const snippet = Snippet.records.get('js/s/format-duration'); - const cssSnippet = Snippet.records.get('css/s/triangle'); - const blogSnippet = Snippet.records.get('articles/s/js-callbacks'); - const reactSnippet = Snippet.records.get('react/s/use-interval'); - expect(snippet.actionType).toEqual('copy'); - expect(cssSnippet.actionType).toEqual('codepen'); - expect(blogSnippet.actionType).toBeUndefined(); - expect(reactSnippet.actionType).toEqual('codepen'); - }); - }); - - describe('property: isScheduled', () => { - it('returns true if the snippet is scheduled', () => { - const snippet = Snippet.records.get('js/s/format-duration'); - expect(snippet.isScheduled).toBe(false); - }); - }); - - describe('property: isPublished', () => { - it('returns true if the snippet is published', () => { - const snippet = Snippet.records.get('js/s/format-duration'); - expect(snippet.isPublished).toBe(true); - }); - }); - - describe('property: isListed', () => { - it('returns true if the snippet is listed', () => { - const snippet = Snippet.records.get('js/s/format-duration'); - expect(snippet.isListed).toBe(true); - }); - }); - - describe('property: ranking', () => { - it('returns a value between 0 and 1', () => { - const snippet = Snippet.records.get('js/s/format-duration'); - expect(snippet.ranking).toBeGreaterThanOrEqual(0); - expect(snippet.ranking).toBeLessThanOrEqual(1); - }); - }); - - describe('property: searchTokensArray', () => { - it('returns an array of search tokens', () => { - const snippet = Snippet.records.get('js/s/format-duration'); - expect(snippet.searchTokensArray).toEqual([ - 'formatduration', - 'js', - 'javascript', - 'date', - 'math', - 'string', - 'human-read', - 'format', - 'given', - 'number', - 'millisecond', - 'formatdur', - ]); - }); - }); - - describe('property: searchTokens', () => { - it('returns a string of search tokens', () => { - const snippet = Snippet.records.get('js/s/format-duration'); - expect(snippet.searchTokens).toEqual( - 'formatduration js javascript date math string human-read format given number millisecond formatdur' - ); - }); - }); - - describe('property: breadcrumbs', () => { - it('returns the breadcrumbs for regular snippets', () => { - const snippet = Snippet.records.get('js/s/format-duration'); - expect(snippet.breadcrumbs).toEqual([ - { - url: '/', - name: 'Home', - }, - { - url: '/js/p/1', - name: 'JavaScript', - }, - { - url: '/js/t/date/p/1', - name: 'Date', - }, - { - url: '/js/s/format-duration', - name: 'formatDuration', - }, - ]); - }); - - it('returns the breadcrumbs for blog snippets', () => { - const snippet = Snippet.records.get('articles/s/js-callbacks'); - expect(snippet.breadcrumbs).toEqual([ - { - url: '/', - name: 'Home', - }, - { - url: '/js/p/1', - name: 'JavaScript', - }, - { - url: '/articles/s/js-callbacks', - name: 'What is a callback function?', - }, - ]); - }); - }); - - describe('property: hasCollection', () => { - it('returns true if the snippet has a collection', () => { - const snippet = Snippet.records.get( - 'articles/s/react-rendering-basics' - ); - expect(snippet.hasCollection).toBe(true); - }); - - it('returns false if the snippet does not have a collection', () => { - const snippet = Snippet.records.get('css/s/triangle'); - expect(snippet.hasCollection).toBe(false); - }); - }); - - describe('property: recommendedCollection', () => { - it('returns the recommended collection if it exists', () => { - const snippet = Snippet.records.get( - 'articles/s/react-rendering-basics' - ); - expect(snippet.recommendedCollection.id).toEqual('react-rendering'); - }); - - it('returns null if there is no recommended collection', () => { - const snippet = Snippet.records.get('css/s/triangle'); - expect(snippet.recommendedCollection).toBeNull(); - }); - }); - - describe('property: language', () => { - it('returns the correct language for a regular snippet', () => { - const snippet = Snippet.records.get('js/s/format-duration'); - expect(snippet.language.id).toEqual('javascript'); - }); - - it('returns null for a regular blog', () => { - const snippet = Snippet.records.get( - 'articles/s/10-vs-code-extensions-for-js-developers' - ); - expect(snippet.language).toBe(null); - }); - - it('returns the correct language for a blog with a language', () => { - const snippet = Snippet.records.get( - 'articles/s/react-rendering-basics' - ); - expect(snippet.language.id).toEqual('react'); - }); - }); - - describe('property: recommendedSnippets', () => { - it('returns a set of recommended snippets', () => { - const snippet = Snippet.records.get('css/s/triangle'); - expect(snippet.recommendedSnippets.length).not.toEqual(0); - }); - }); - - describe('scope: snippets', () => { - it('returns a set of snippets', () => { - const snippets = Snippet.records.snippets; - expect(snippets.length).toEqual(8); - }); - }); - - describe('scope: blogs', () => { - it('returns a set of blogs', () => { - const snippets = Snippet.records.blogs; - expect(snippets.length).toEqual(7); - }); - }); - - describe('scope: listed', () => { - it('returns a set of listed snippets', () => { - const snippets = Snippet.records.listed; - expect(snippets.length).toEqual(15); - }); - }); - - describe('scope: listedByPopularity', () => { - it('returns a set of listed snippets', () => { - const snippets = Snippet.records.listedByPopularity; - expect(snippets.length).toEqual(15); - }); - }); - - describe('scope: listedByNew', () => { - it('returns a set of listed snippets', () => { - const snippets = Snippet.records.listedByNew; - expect(snippets.length).toEqual(15); - }); - }); - - describe('scope: unlisted', () => { - it('returns a set of unlisted snippets', () => { - const snippets = Snippet.records.unlisted; - expect(snippets.length).toEqual(0); - }); - }); - - describe('scope: scheduled', () => { - it('returns a set of scheduled snippets', () => { - const snippets = Snippet.records.scheduled; - expect(snippets.length).toEqual(0); - }); - }); - - describe('scope: published', () => { - it('returns a set of published snippets', () => { - const snippets = Snippet.records.published; - expect(snippets.length).toEqual(15); - }); - }); - }); - - describe('Listing', () => { - const Listing = Application.dataset.getModel('Listing'); - - describe('property: isMain', () => { - it('returns true if the listing is the main listing', () => { - const listing = Listing.records.get('main'); - expect(listing.isMain).toBe(true); - }); - - it('returns false if the listing is not the main listing', () => { - const listing = Listing.records.get('blog/articles'); - expect(listing.isMain).toBe(false); - }); - }); - - describe('property: isBlog', () => { - it('returns true if the listing is the main listing', () => { - const listing = Listing.records.get('blog/articles'); - expect(listing.isBlog).toBe(true); - }); - - it('returns false if the listing is not the main listing', () => { - const listing = Listing.records.get('main'); - expect(listing.isBlog).toBe(false); - }); - }); - - describe('property: isBlogTag', () => { - it('returns true if the listing is a blog tag', () => { - const listing = Listing.records.get('tag/articles/t/javascript'); - expect(listing.isBlogTag).toBe(true); - }); - - it('returns false if the listing is not a blog tag', () => { - const listing = Listing.records.get('tag/js/t/string'); - expect(listing.isBlogTag).toBe(false); - }); - }); - - describe('property: isLanguage', () => { - it('returns true if the listing is a language', () => { - const listing = Listing.records.get('language/js'); - expect(listing.isLanguage).toBe(true); - }); - - it('returns false if the listing is not a language', () => { - const listing = Listing.records.get('tag/js/t/string'); - expect(listing.isLanguage).toBe(false); - }); - }); - - describe('property: isTopLevel', () => { - it('returns true if the listing is top level', () => { - const listing = Listing.records.get('language/js'); - expect(listing.isTopLevel).toBe(true); - }); - - it('returns false if the listing is not top level', () => { - const listing = Listing.records.get('tag/js/t/string'); - expect(listing.isTopLevel).toBe(false); - }); - }); - - describe('property: isTag', () => { - it('returns true if the listing is a tag', () => { - const listing = Listing.records.get('tag/js/t/string'); - expect(listing.isTag).toBe(true); - }); - - it('returns false if the listing is not a tag', () => { - const listing = Listing.records.get('language/js'); - expect(listing.isTag).toBe(false); - }); - }); - - describe('property: isCollection', () => { - it('returns true if the listing is a collection', () => { - const listing = Listing.records.get('collection/c/tips'); - expect(listing.isCollection).toBe(true); - }); - - it('returns false if the listing is not a collection', () => { - const listing = Listing.records.get('language/js'); - expect(listing.isCollection).toBe(false); - }); - }); - - describe('property: isParent', () => { - it('returns true if the listing is a parent', () => { - const listing = Listing.records.get('language/js'); - expect(listing.isParent).toBe(true); - }); - - it('returns false if the listing is not a parent', () => { - const listing = Listing.records.get('collection/c/tips'); - expect(listing.isParent).toBe(false); - }); - }); - - describe('property: isLeaf', () => { - it('returns true if the listing is a leaf', () => { - const listing = Listing.records.get('collection/c/tips'); - expect(listing.isLeaf).toBe(true); - }); - - it('returns false if the listing is not a leaf', () => { - const listing = Listing.records.get('language/js'); - expect(listing.isLeaf).toBe(false); - }); - }); - - describe('property: isRoot', () => { - it('returns true if the listing is a root', () => { - const listing = Listing.records.get('language/js'); - expect(listing.isRoot).toBe(true); - }); - - it('returns false if the listing is not a root', () => { - const listing = Listing.records.get('tag/js/t/string'); - expect(listing.isRoot).toBe(false); - }); - }); - - describe('property: rootUrl', () => { - it('returns the root url for a root', () => { - const listing = Listing.records.get('language/js'); - expect(listing.rootUrl).toEqual('/js'); - }); - - it('returns the root url for a leaf', () => { - const listing = Listing.records.get('tag/js/t/string'); - expect(listing.rootUrl).toEqual('/js'); - }); - }); - - describe('property: siblings', () => { - it('returns the siblings for a listing', () => { - const listing = Listing.records.get('tag/js/t/string'); - expect(listing.siblings.length).toEqual(3); - }); - }); - - describe('property: siblingsExceptSelf', () => { - it('returns the siblings for a listing', () => { - const listing = Listing.records.get('tag/js/t/string'); - expect(listing.siblingsExceptSelf.length).toEqual(2); - }); - }); - - describe('property: sublinks', () => { - it('returns the sublinks for a parent listing', () => { - const listing = Listing.records.get('language/js'); - expect(listing.sublinks).toEqual([ - { - title: 'All', - url: '/js/p/1', - selected: true, - }, - { - title: 'Date', - url: '/js/t/date/p/1', - selected: false, - }, - { - title: 'Node', - url: '/js/t/node/p/1', - selected: false, - }, - { - title: 'String', - url: '/js/t/string/p/1', - selected: false, - }, - ]); - }); - it('returns the sublinks for a child listing', () => { - const listing = Listing.records.get('tag/js/t/string'); - expect(listing.sublinks).toEqual([ - { - title: 'All', - url: '/js/p/1', - selected: false, - }, - { - title: 'Date', - url: '/js/t/date/p/1', - selected: false, - }, - { - title: 'Node', - url: '/js/t/node/p/1', - selected: false, - }, - { - title: 'String', - url: '/js/t/string/p/1', - selected: true, - }, - ]); - }); - }); - - describe('property: ranking', () => { - it('returns the ranking for a listing', () => { - const listing = Listing.records.get('tag/js/t/string'); - expect(listing.ranking).toBeGreaterThanOrEqual(0); - expect(listing.ranking).toBeLessThanOrEqual(1); - }); - }); - - describe('property: name', () => { - it('returns the name for a listing', () => { - const listing = Listing.records.get('tag/js/t/string'); - expect(listing.name).toEqual('JavaScript String Snippets'); - }); - }); - - describe('property: shortName', () => { - it('returns the short name for a listing', () => { - const listing = Listing.records.get('tag/js/t/string'); - expect(listing.shortName).toEqual('JavaScript String'); - }); - }); - - describe('property: description', () => { - it('returns the description for a listing', () => { - const Tag = Application.dataset.getModel('Tag'); - const tag = Tag.records.get('30code_string'); - const listing = Listing.records.get('tag/js/t/string'); - expect(listing.description).toEqual(tag.description); - }); - }); - - describe('property: shortDescription', () => { - it('returns the description for a listing', () => { - const Tag = Application.dataset.getModel('Tag'); - const tag = Tag.records.get('30code_string'); - const listing = Listing.records.get('tag/js/t/string'); - expect(listing.shortDescription).toEqual( - `

    ${tag.shortDescription}

    ` - ); - }); - }); - - describe('property: splash', () => { - it('returns the splash for a listing', () => { - const listing = Listing.records.get('tag/js/t/string'); - expect(listing.splash).toEqual('laptop-plant.png'); - }); - }); - - describe('property: seoDescription', () => { - it('returns the SEO description for a listing', () => { - const listing = Listing.records.get('tag/js/t/string'); - expect(listing.seoDescription).toEqual( - 'Browse 3 JavaScript String code snippets for all your development needs on 30 seconds of code.' - ); - }); - }); - - describe('property: featured', () => { - it('returns the featured for a listing', () => { - const listing = Listing.records.get('tag/js/t/string'); - expect(listing.featured).not.toEqual(false); - }); - }); - - describe('property: isListed', () => { - it('returns true for listed listings', () => { - const mainListing = Listing.records.get('main'); - const blogListing = Listing.records.get('blog/articles'); - const languageListing = Listing.records.get('language/js'); - const tagListing = Listing.records.get('tag/js/t/string'); - expect(mainListing.isListed).toEqual(true); - expect(blogListing.isListed).toEqual(true); - expect(languageListing.isListed).toEqual(true); - expect(tagListing.isListed).toEqual(true); - }); - }); - - describe('property: isSearchable', () => { - it('returns true for searchable listings', () => { - const mainListing = Listing.records.get('main'); - const blogListing = Listing.records.get('blog/articles'); - const languageListing = Listing.records.get('language/js'); - const tagListing = Listing.records.get('tag/js/t/string'); - expect(mainListing.isSearchable).toEqual(false); - expect(blogListing.isSearchable).toEqual(false); - expect(languageListing.isSearchable).toEqual(true); - expect(tagListing.isSearchable).toEqual(true); - }); - }); - - describe('property: searchTokens', () => { - it('returns a string of string tokens for the listing', () => { - const listing = Listing.records.get('tag/js/t/string'); - expect(listing.searchTokens).toEqual( - 'brows wide varieti es6 helper function includ arrai oper dom manipul algorithm node js util javascript string snippet' - ); - }); - }); - - describe('property: pageCount', () => { - it('returns the page count for a listing', () => { - const listing = Listing.records.get('tag/js/t/string'); - expect(listing.pageCount).toEqual(1); - }); - }); - - describe('property: defaultOrdering', () => { - it('returns "popularity" for a regular listing', () => { - const listing = Listing.records.get('tag/js/t/string'); - expect(listing.defaultOrdering).toEqual('popularity'); - }); - - it('returns "new" for a blog listing', () => { - const listing = Listing.records.get('blog/articles'); - expect(listing.defaultOrdering).toEqual('new'); - }); - - it('returns "custom" for collection listings', () => { - const listing = Listing.records.get('collection/c/react-rendering'); - expect(listing.defaultOrdering).toEqual('custom'); - }); - }); - - describe('property: listedSnippets', () => { - it('returns a set of snippets for a listing', () => { - const listing = Listing.records.get('language/js'); - expect(listing.listedSnippets.length).toBe(6); - }); - }); - - // No reason testing property: data, as it's already tested via the other - // properties. - - describe('property: snippets', () => { - it('returns a set of snippets for a listing', () => { - const listing = Listing.records.get('language/js'); - expect(listing.snippets.length).toBe(6); - }); - }); - - describe('method: pageRanking', () => { - it("returns the page ranking for a listing's pages", () => { - const listing = Listing.records.get('tag/js/t/string'); - const firstPageRanking = listing.pageRanking(1); - const secondPageRanking = listing.pageRanking(2); - expect(firstPageRanking).toBeGreaterThanOrEqual(0); - expect(firstPageRanking).toBeLessThanOrEqual(1); - expect(secondPageRanking).toBeGreaterThanOrEqual(0); - expect(secondPageRanking).toBeLessThanOrEqual(1); - expect(secondPageRanking).toBeLessThan(firstPageRanking); - }); - }); - - describe('scope: main', () => { - it('returns the main listing', () => { - const mainListing = Listing.records.main.first; - expect(mainListing.id).toEqual('main'); - }); - }); - - describe('scope: blog', () => { - it('returns the blog listing', () => { - const blogListing = Listing.records.blog.first; - expect(blogListing.id).toEqual('blog/articles'); - }); - }); - - describe('scope: language', () => { - it('returns the language listings', () => { - const languageListings = Listing.records.language; - expect(languageListings.length).toBe(3); - }); - }); - - describe('scope: tag', () => { - it('returns the tag listings', () => { - const tagListings = Listing.records.tag; - expect(tagListings.length).toEqual(9); - }); - }); - - describe('scope: collection', () => { - it('returns the collection listings', () => { - const collectionListings = Listing.records.collection; - expect(collectionListings.length).toEqual(2); - }); - }); - - describe('scope: listed', () => { - it('returns the listed listings', () => { - const listedListings = Listing.records.listed; - expect(listedListings.length).toEqual(16); - }); - }); - - describe('scope: searchable', () => { - it('returns the searchable listings', () => { - const searchableListings = Listing.records.searchable; - expect(searchableListings.length).toEqual(11); - }); - }); - - describe('scope: featured', () => { - it('returns the featured listings', () => { - const featuredListings = Listing.records.featured; - expect(featuredListings.length).toEqual(8); - }); - }); - }); - - describe('Page', () => { - const Page = Application.dataset.getModel('Page'); - - describe('property: isStatic', () => { - it('returns true for static pages', () => { - const page = Page.records.get('about'); - expect(page.isStatic).toEqual(true); - }); - }); - - describe('property: isCollectionsListing', () => { - it('returns true for the collections listing pages', () => { - const page = Page.records.get('listing_collections_1'); - expect(page.isCollectionsListing).toEqual(true); - }); - - it('returns false for all other pages', () => { - const page = Page.records.get('listing_language/js_1'); - expect(page.isCollectionsListing).toEqual(false); - }); - }); - - describe('property: isSnippet', () => { - it('returns true for snippet pages', () => { - const page = Page.records.get('snippet_css/s/triangle'); - expect(page.isSnippet).toEqual(true); - }); - - it('returns false for al other pages', () => { - const page = Page.records.get('home'); - expect(page.isSnippet).toEqual(false); - }); - }); - - describe('property: isListing', () => { - it('returns true for regular listing pages', () => { - const page = Page.records.get('listing_language/js_1'); - expect(page.isListing).toEqual(true); - }); - - it('returns false for all other pages', () => { - const page = Page.records.get('snippet_css/s/triangle'); - expect(page.isListing).toEqual(false); - }); - }); - - describe('property: isHome', () => { - it('returns true for the home page', () => { - const page = Page.records.get('home'); - expect(page.isHome).toEqual(true); - }); - - it('returns false for al other pages', () => { - const page = Page.records.get('snippet_css/s/triangle'); - expect(page.isHome).toEqual(false); - }); - }); - - describe('property: isUnlisted', () => { - it('returns the correct value', () => { - const page = Page.records.get('home'); - expect(page.isUnlisted).toEqual(false); - }); - }); - - describe('property: isPublished', () => { - it('returns the correct value', () => { - const page = Page.records.get('home'); - expect(page.isPublished).toEqual(true); - }); - }); - - describe('property: isIndexable', () => { - it('returns true for regular pages', () => { - const page = Page.records.get('home'); - expect(page.isIndexable).toEqual(true); - }); - - it('returns false for the 404 page', () => { - const page = Page.records.get('404'); - expect(page.isIndexable).toEqual(false); - }); - }); - - describe('property: priority', () => { - it('returns the correct value', () => { - const page = Page.records.get('snippet_css/s/triangle'); - expect(page.priority).toBeGreaterThanOrEqual(0); - expect(page.priority).toBeLessThanOrEqual(1); - }); - }); - - describe('property: relRoute', () => { - it('returns the correct value', () => { - const page = Page.records.get('snippet_css/s/triangle'); - expect(page.relRoute).toEqual('/css/s/triangle'); - }); - }); - - describe('property: fullRoute', () => { - it('returns the correct value', () => { - const page = Page.records.get('snippet_css/s/triangle'); - expect(page.fullRoute).toEqual( - 'https://www.30secondsofcode.org/css/s/triangle' - ); - }); - }); - - // No reason testing property: data, as it's already tested via the other - // properties. - - describe('property: context', () => { - it('returns the correct value for the home page', () => { - const page = Page.records.get('home'); - const pageContext = page.context; - expect(pageContext.pageDescription).toEqual( - 'Browse 15 short code snippets for all your development needs on 30 seconds of code.' - ); - }); - - it('returns the correct value for the collections page', () => { - const page = Page.records.get('listing_collections_1'); - const pageContext = page.context; - expect(pageContext.listingName).toEqual('Snippet Collections'); - expect(pageContext.pageDescription).toEqual( - 'Browse 8 snippet collections on 30 seconds of code.' - ); - expect(pageContext.slug).toEqual('/collections/p/1'); - expect(pageContext.snippetList.length).toEqual(8); - }); - - it('returns the correct value for the search page', () => { - const page = Page.records.get('search'); - const pageContext = page.context; - expect(pageContext.searchIndex.length).toBe(26); - }); - - it('returns the correct value for a listing page', () => { - const page = Page.records.get('listing_blog/articles_1'); - const pageContext = page.context; - expect(pageContext.listingName).toEqual('Articles'); - expect(pageContext.paginator).toBe(null); - expect(pageContext.snippetList.length).toEqual(7); - expect(pageContext.pageDescription).toEqual( - 'Browse 7 code articles for all your development needs on 30 seconds of code.' - ); - }); - - it('returns the correct value for a snippet page', () => { - const page = Page.records.get('snippet_css/s/triangle'); - const pageContext = page.context; - expect(pageContext.breadcrumbs.length).toBe(4); - expect(pageContext.pageDescription).toEqual( - 'Creates a triangular shape with pure CSS.' - ); - expect(pageContext.recommendations.length).toBe(3); - }); - }); - }); -}); diff --git a/src/test/blocks/utilities/content.test.js b/src/test/blocks/utilities/content.test.js deleted file mode 100644 index 4850aa6cc..000000000 --- a/src/test/blocks/utilities/content.test.js +++ /dev/null @@ -1,53 +0,0 @@ -import childProcess from 'child_process'; -import { Content } from 'blocks/utilities/content'; -jest.mock('child_process'); - -describe.skip('Content', () => { - describe('init', () => { - let res; - - beforeAll(() => { - res = Content.init(); - }); - - it('should return a Promise', () => { - expect(res instanceof Promise).toBe(true); - }); - - it('should execute the appropriate git command', () => { - const proc = childProcess.spawn.mock.calls[0]; - expect(proc[0]).toBe('git'); - expect(proc[1]).toEqual([ - 'submodule', - 'update', - '--init', - '--recursive', - '--progress', - ]); - }); - }); - - describe('update', () => { - let res; - - beforeAll(() => { - res = Content.update(); - }); - - it('should return a Promise', () => { - expect(res instanceof Promise).toBe(true); - }); - - it('should execute the appropriate git command', () => { - const proc = childProcess.spawn.mock.calls[1]; - expect(proc[0]).toBe('git'); - expect(proc[1]).toEqual([ - 'submodule', - 'update', - '--recursive', - '--remote', - '--depth=10000', - ]); - }); - }); -}); From 6b5ec054d7c380321bc5d48c298539761fff21a6 Mon Sep 17 00:00:00 2001 From: Angelos Chalaris Date: Sun, 7 May 2023 13:49:27 +0300 Subject: [PATCH 32/44] Update Content utility --- src/blocks/utilities/content.js | 115 +++----------------------------- 1 file changed, 10 insertions(+), 105 deletions(-) diff --git a/src/blocks/utilities/content.js b/src/blocks/utilities/content.js index 2aa3db094..b1e6197ca 100644 --- a/src/blocks/utilities/content.js +++ b/src/blocks/utilities/content.js @@ -3,7 +3,6 @@ import path from 'path'; import fs from 'fs'; import pathSettings from 'settings/paths'; import { Logger } from 'blocks/utilities/logger'; -import { JSONHandler } from 'blocks/utilities/jsonHandler'; export class Content { /** @@ -82,118 +81,25 @@ export class Content { }; /** - * Adds a new content source - * @param {string} repoUrl - GitHub repositoy URL (e.g. 'https://github.com/30-seconds/30-seconds-of-yada') - * @param {string} dirName - Directory name (e.g. '30yada') - * @param {string} name - Name (e.g. '30 seconds of yada') - * @param {string} slug - Slug (e.g. 'yada') - * Returns a promise that resolves as soon as the spawned git command exits. - */ - static create = (repoUrl, dirName, name, slug) => { - const logger = new Logger('Content.create'); - logger.log('Creating new content source...'); - - /* istanbul ignore next */ - return new Promise((resolve, reject) => { - const gitAdd = childProcess.spawn('git', [ - 'submodule', - 'add', - '-b', - 'master', - repoUrl, - `./content/sources/${dirName}`, - ]); - logger.log(`${gitAdd.spawnargs.join(' ')} (pid: ${gitAdd.pid})`); - - gitAdd.stdout.on('data', data => { - logger.log(`${data}`.replace('\n', '')); - }); - gitAdd.on('error', err => { - logger.error(`${err}`); - reject(); - }); - gitAdd.on('exit', () => resolve()); - }) - .then( - () => - new Promise((resolve, reject) => { - const gitConfig = childProcess.spawn('git', [ - 'config', - '-f', - '.gitmodules', - `submodule.content/sources/${dirName}.update`, - 'checkout', - ]); - logger.log( - `${gitConfig.spawnargs.join(' ')} (pid: ${gitConfig.pid})` - ); - - gitConfig.stdout.on('data', data => { - logger.log(`${data}`.replace('\n', '')); - }); - gitConfig.on('error', err => { - logger.error(`${err}`); - reject(); - }); - gitConfig.on('exit', () => resolve()); - }) - ) - .then( - () => - new Promise((resolve, reject) => { - const gitUpdate = childProcess.spawn('git', [ - 'submodule', - 'update', - '--remote', - ]); - logger.log( - `${gitUpdate.spawnargs.join(' ')} (pid: ${gitUpdate.pid})` - ); - - gitUpdate.stdout.on('data', data => { - logger.log(`${data}`.replace('\n', '')); - }); - gitUpdate.on('error', err => { - logger.error(`${err}`); - reject(); - }); - gitUpdate.on('exit', code => { - logger.success( - `Creating content source completed with exit code ${code}` - ); - return JSONHandler.toFile( - `./content/configs/repos/${dirName}.json`, - { - name, - dirName, - repoUrl, - slug, - featured: true, - } - ).then(() => resolve()); - }); - }) - ); - }; - - /** - * Creates a new snippet from the template in the given content source - * @param {string} submoduleName - Name of the submodule (e.g. '30blog') + * Creates a new snippet from the template in the given content directory + * @param {string} directoryName - Name of the directory (e.g. 'articles') * @param {string} snippetName - Name of the new snippet (e.g. 'my-blog-post') */ - static createSnippet = (submoduleName, snippetName) => { + static createSnippet = (directoryName, snippetName) => { const logger = new Logger('Content.createSnippet'); - logger.log(`Creating new snippet ${snippetName} in ${submoduleName}...`); + logger.log(`Creating new snippet ${snippetName} in ${directoryName}...`); const { rawContentPath: contentPath } = pathSettings; - const submodulePath = path.join( + // TODO: Temporary change, move the content directory as needed + const directoryPath = path.join( process.cwd(), contentPath, 'sources', - submoduleName + '30code', + directoryName ); - const templatePath = path.join(submodulePath, 'snippet-template.md'); - const snippetPath = path.join(submodulePath, 'snippets'); + const templatePath = path.join(directoryPath, 'template.md'); + const snippetPath = path.join(directoryPath, 's'); try { if (!fs.existsSync(snippetPath)) { logger.log('Snippet directory not found! Creating directory...'); @@ -214,7 +120,6 @@ export class Content { logger.success('Snippet creation complete!'); } catch (err) { - logger.error('Snippet creation encountered an error'); logger.error(err); } }; From 5e51d8dbe8e4e417b762c390955e9018b8af7795 Mon Sep 17 00:00:00 2001 From: Angelos Chalaris Date: Sun, 7 May 2023 15:59:35 +0300 Subject: [PATCH 33/44] Update FAQ to remove multiple repos --- src/pages/faq.astro | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/src/pages/faq.astro b/src/pages/faq.astro index 5bb6d1566..dee5977fa 100644 --- a/src/pages/faq.astro +++ b/src/pages/faq.astro @@ -29,10 +29,9 @@ const pageDescription = itemtype='https://schema.org/Answer' >

    - Any content issues should be reported using the GitHub Issue Tracker - for each individual content repository. Use the GitHub link on the - snippet page to find the appropriate repository and then open an issue - describing the problem. + Any content issues should be reported using the GitHub Issue Tracker. + Use the GitHub lin on the snippet page to find the matching file and + then open an issue describing the problem.

    @@ -54,8 +53,7 @@ const pageDescription =

    There are no specific requirements for contributing to 30 seconds of code, except for reading and understanding our contribution - guidelines. These can be found on each individual repository, while - some more general guidelines can be found here Date: Sun, 7 May 2023 16:01:39 +0300 Subject: [PATCH 34/44] Update content sources --- content/configs | 2 +- content/sources/30blog | 2 +- content/sources/30code | 2 +- content/sources/30css | 2 +- content/sources/30git | 2 +- content/sources/30python | 2 +- content/sources/30react | 2 +- src/scripts/build.js | 1 + 8 files changed, 8 insertions(+), 7 deletions(-) diff --git a/content/configs b/content/configs index 09a30ab39..452ef147b 160000 --- a/content/configs +++ b/content/configs @@ -1 +1 @@ -Subproject commit 09a30ab390a972ea3d1eabc4fc0476479b0b58f5 +Subproject commit 452ef147b51043acc5152fb5b3548fb4b3bb0d12 diff --git a/content/sources/30blog b/content/sources/30blog index 40028eb1d..e73725888 160000 --- a/content/sources/30blog +++ b/content/sources/30blog @@ -1 +1 @@ -Subproject commit 40028eb1d7bc2cc07ac33a59da3665f7848375a1 +Subproject commit e737258888dcfe2fb19575a938312e66f13566bd diff --git a/content/sources/30code b/content/sources/30code index 65e2ece57..2ecadbada 160000 --- a/content/sources/30code +++ b/content/sources/30code @@ -1 +1 @@ -Subproject commit 65e2ece57f327638ae5b6de375eeb082d4166e07 +Subproject commit 2ecadbada9a18e2420d1c8123e81c1bc1c6c3839 diff --git a/content/sources/30css b/content/sources/30css index cf2a2bd7c..334b4dd6f 160000 --- a/content/sources/30css +++ b/content/sources/30css @@ -1 +1 @@ -Subproject commit cf2a2bd7ce49c2de9e973412383495e3fc66365e +Subproject commit 334b4dd6f89aa9076981c5656f9c617b02c8f569 diff --git a/content/sources/30git b/content/sources/30git index 20e149ed7..7482512ab 160000 --- a/content/sources/30git +++ b/content/sources/30git @@ -1 +1 @@ -Subproject commit 20e149ed7578f4dee761d2abed4aef97eb41c2e1 +Subproject commit 7482512abf2c3bb7fb29f82585bdc9c4682a7e20 diff --git a/content/sources/30python b/content/sources/30python index 3390ba433..ab1ea476c 160000 --- a/content/sources/30python +++ b/content/sources/30python @@ -1 +1 @@ -Subproject commit 3390ba43379ad6429afdb22ccfcd236325bbed94 +Subproject commit ab1ea476c5d6740cca9f3923414095cec407ba50 diff --git a/content/sources/30react b/content/sources/30react index 4b9d229e3..b38ff6bd9 160000 --- a/content/sources/30react +++ b/content/sources/30react @@ -1 +1 @@ -Subproject commit 4b9d229e3869f146ca12fcd8567e7b91c6bcf1e9 +Subproject commit b38ff6bd9d2241843c9afa37b14b1b7350be4781 diff --git a/src/scripts/build.js b/src/scripts/build.js index 4f194c385..b8ca21a48 100644 --- a/src/scripts/build.js +++ b/src/scripts/build.js @@ -10,6 +10,7 @@ export const build = async () => { Application.AssetWriter.write(), Application.SearchIndexWriter.write(), Application.PageWriter.write(), + // TODO: Replace with Astro plugin Application.SitemapWriter.write(), Application.FeedWriter.write(), ]); From 706bcf0300fb45218ed5f9e8ec95e4198bc887f5 Mon Sep 17 00:00:00 2001 From: Angelos Chalaris Date: Sun, 7 May 2023 16:03:57 +0300 Subject: [PATCH 35/44] Remove deprecated submodules --- .gitmodules | 25 ------------------------- content/sources/30blog | 1 - content/sources/30css | 1 - content/sources/30git | 1 - content/sources/30python | 1 - content/sources/30react | 1 - 6 files changed, 30 deletions(-) delete mode 160000 content/sources/30blog delete mode 160000 content/sources/30css delete mode 160000 content/sources/30git delete mode 160000 content/sources/30python delete mode 160000 content/sources/30react diff --git a/.gitmodules b/.gitmodules index df3846aa0..22f2e94c4 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,35 +1,10 @@ -[submodule "content/sources/30python"] - path = content/sources/30python - url = https://github.com/30-seconds/30-seconds-of-python - branch = master - update = checkout [submodule "content/sources/30code"] path = content/sources/30code url = https://github.com/30-seconds/30-seconds-of-code branch = master update = checkout -[submodule "content/sources/30css"] - path = content/sources/30css - url = https://github.com/30-seconds/30-seconds-of-css - branch = master - update = checkout -[submodule "content/sources/30react"] - path = content/sources/30react - url = https://github.com/30-seconds/30-seconds-of-react - branch = master - update = checkout -[submodule "content/sources/30blog"] - path = content/sources/30blog - url = https://github.com/30-seconds/30-seconds-blog - branch = master - update = checkout [submodule "content/configs"] path = content/configs url = https://github.com/30-seconds/30-seconds-content branch = master update = checkout -[submodule "content/sources/30git"] - path = content/sources/30git - url = https://github.com/30-seconds/30-seconds-of-git - branch = master - update = checkout diff --git a/content/sources/30blog b/content/sources/30blog deleted file mode 160000 index e73725888..000000000 --- a/content/sources/30blog +++ /dev/null @@ -1 +0,0 @@ -Subproject commit e737258888dcfe2fb19575a938312e66f13566bd diff --git a/content/sources/30css b/content/sources/30css deleted file mode 160000 index 334b4dd6f..000000000 --- a/content/sources/30css +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 334b4dd6f89aa9076981c5656f9c617b02c8f569 diff --git a/content/sources/30git b/content/sources/30git deleted file mode 160000 index 7482512ab..000000000 --- a/content/sources/30git +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 7482512abf2c3bb7fb29f82585bdc9c4682a7e20 diff --git a/content/sources/30python b/content/sources/30python deleted file mode 160000 index ab1ea476c..000000000 --- a/content/sources/30python +++ /dev/null @@ -1 +0,0 @@ -Subproject commit ab1ea476c5d6740cca9f3923414095cec407ba50 diff --git a/content/sources/30react b/content/sources/30react deleted file mode 160000 index b38ff6bd9..000000000 --- a/content/sources/30react +++ /dev/null @@ -1 +0,0 @@ -Subproject commit b38ff6bd9d2241843c9afa37b14b1b7350be4781 From 32c86e729e3d485f187c944b10c12c6ec41a1e22 Mon Sep 17 00:00:00 2001 From: Angelos Chalaris Date: Sun, 7 May 2023 16:18:15 +0300 Subject: [PATCH 36/44] Squash all content --- .gitmodules | 7 +------ content/sources/30code => content | 0 content/configs | 1 - 3 files changed, 1 insertion(+), 7 deletions(-) rename content/sources/30code => content (100%) delete mode 160000 content/configs diff --git a/.gitmodules b/.gitmodules index 22f2e94c4..a4aea8083 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,10 +1,5 @@ [submodule "content/sources/30code"] - path = content/sources/30code + path = content url = https://github.com/30-seconds/30-seconds-of-code branch = master update = checkout -[submodule "content/configs"] - path = content/configs - url = https://github.com/30-seconds/30-seconds-content - branch = master - update = checkout diff --git a/content/sources/30code b/content similarity index 100% rename from content/sources/30code rename to content diff --git a/content/configs b/content/configs deleted file mode 160000 index 452ef147b..000000000 --- a/content/configs +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 452ef147b51043acc5152fb5b3548fb4b3bb0d12 From 61f2379c9df654353af04ed619c1e91ecfa7208d Mon Sep 17 00:00:00 2001 From: Angelos Chalaris Date: Sun, 7 May 2023 16:18:32 +0300 Subject: [PATCH 37/44] Update paths for new content repo --- src/blocks/extractor/index.js | 10 +++++----- src/blocks/models/snippet.js | 2 +- src/blocks/utilities/content.js | 1 + src/blocks/utilities/ranker.js | 2 +- src/blocks/writers/assetWriter.js | 2 +- 5 files changed, 9 insertions(+), 8 deletions(-) diff --git a/src/blocks/extractor/index.js b/src/blocks/extractor/index.js index df66643ea..66d73ce9b 100644 --- a/src/blocks/extractor/index.js +++ b/src/blocks/extractor/index.js @@ -44,7 +44,7 @@ export class Extractor { const logger = new Logger('Extractor.extractCollectionConfigs'); logger.log('Extracting collection configurations'); const configs = YAMLHandler.fromGlob( - `${contentDir}/configs/collections/**/*.yaml`, + `${contentDir}/collections/**/*.yaml`, { withNames: true } ).map(([path, config]) => { const { @@ -85,7 +85,7 @@ export class Extractor { const logger = new Logger('Extractor.extractAuthors'); logger.log('Extracting authors'); const authors = Object.entries( - YAMLHandler.fromFile(`${contentDir}/configs/authors.yaml`) + YAMLHandler.fromFile(`${contentDir}/authors.yaml`) ).map(([id, author]) => { return { ...author, @@ -100,7 +100,7 @@ export class Extractor { const logger = new Logger('Extractor.extractLanguageData'); logger.log('Extracting language data'); const languageData = YAMLHandler.fromGlob( - `${contentDir}/configs/languages/*.yaml` + `${contentDir}/languages/*.yaml` ).reduce((acc, language) => { const { short, long, name, references = {} } = language; acc.set(long, { @@ -120,7 +120,7 @@ export class Extractor { const logger = new Logger('Extractor.extractSnippets'); logger.log('Extracting snippets'); - const snippetsGlob = `${contentDir}/sources/30code/**/s/*.md`; + const snippetsGlob = `${contentDir}/snippets/**/s/*.md`; MarkdownParser.loadLanguageData([...languageData.values()]); let snippets = []; @@ -255,7 +255,7 @@ export class Extractor { static extractCollectionsHubConfig = contentDir => { const logger = new Logger('Extractor.extractCollectionsHubConfig'); logger.log('Extracting hub pages configuration'); - const hubConfig = YAMLHandler.fromFile(`${contentDir}/configs/hub.yaml`); + const hubConfig = YAMLHandler.fromFile(`${contentDir}/hub.yaml`); logger.log('Finished extracting hub pages configuration'); return hubConfig; }; diff --git a/src/blocks/models/snippet.js b/src/blocks/models/snippet.js index 7f8cea4d1..2b64cbd3f 100644 --- a/src/blocks/models/snippet.js +++ b/src/blocks/models/snippet.js @@ -46,7 +46,7 @@ export const snippet = { slug: snippet => `/${snippet.id}`, fileSlug: snippet => convertToSeoSlug(snippet.fileName.slice(0, -3)), url: snippet => - `https://github.com/30-seconds/30-seconds-of-code/blob/master${snippet.slug}.md`, + `https://github.com/30-seconds/30-seconds-of-code/blob/master/snippets${snippet.slug}.md`, actionType: snippet => (snippet.code ? 'codepen' : undefined), isScheduled: snippet => snippet.dateModified > new Date(), isPublished: snippet => !snippet.isScheduled, diff --git a/src/blocks/utilities/content.js b/src/blocks/utilities/content.js index b1e6197ca..9734afc4d 100644 --- a/src/blocks/utilities/content.js +++ b/src/blocks/utilities/content.js @@ -96,6 +96,7 @@ export class Content { contentPath, 'sources', '30code', + 'snippets', directoryName ); const templatePath = path.join(directoryPath, 'template.md'); diff --git a/src/blocks/utilities/ranker.js b/src/blocks/utilities/ranker.js index f4079da0f..d0c66d450 100644 --- a/src/blocks/utilities/ranker.js +++ b/src/blocks/utilities/ranker.js @@ -20,7 +20,7 @@ export class Ranker { if (process.env.NODE_ENV === `test`) return {}; if (!Object.keys(Ranker.keywordScoreData).length) { Ranker.keywordScoreData = YAMLHandler.fromFile( - `content/configs/rankingEngine.yaml` + 'content/rankingEngine.yaml' ); } return Ranker.keywordScoreData; diff --git a/src/blocks/writers/assetWriter.js b/src/blocks/writers/assetWriter.js index 84a966729..5e5875b4f 100644 --- a/src/blocks/writers/assetWriter.js +++ b/src/blocks/writers/assetWriter.js @@ -11,7 +11,7 @@ const { rawAssetPath: inPath, assetPath: outPath, } = Application.settings.paths; -const inContentPath = 'content/configs/assets'; +const inContentPath = 'content/assets'; // Image asset constants const supportedExtensions = ['jpeg', 'jpg', 'png', 'webp', 'tif', 'tiff']; From c3fbbcf7a2ff61d44d993802cb9c5e6ac031cf94 Mon Sep 17 00:00:00 2001 From: Angelos Chalaris Date: Sun, 7 May 2023 17:13:43 +0300 Subject: [PATCH 38/44] Fix minor migration bugs --- src/blocks/extractor/index.js | 4 +--- src/blocks/models/collection.js | 5 ----- src/blocks/models/snippetPage.js | 2 +- src/blocks/utilities/content.js | 3 --- src/blocks/writers/feedWriter.js | 14 +++++++------- src/scripts/build.js | 1 - 6 files changed, 9 insertions(+), 20 deletions(-) diff --git a/src/blocks/extractor/index.js b/src/blocks/extractor/index.js index 66d73ce9b..90a64c0b9 100644 --- a/src/blocks/extractor/index.js +++ b/src/blocks/extractor/index.js @@ -157,9 +157,7 @@ export class Extractor { ? ['js', 'jsx'] : [language]; - const id = filePath - .replace(`${contentDir}/sources/30code/`, '') - .slice(0, -3); + const id = filePath.replace(`${contentDir}/snippets/`, '').slice(0, -3); const tags = rawTags.map(tag => tag.toLowerCase()); const bodyText = body diff --git a/src/blocks/models/collection.js b/src/blocks/models/collection.js index 10aa0606e..aa0581a74 100644 --- a/src/blocks/models/collection.js +++ b/src/blocks/models/collection.js @@ -3,11 +3,6 @@ import tokenizeCollection from 'utils/search'; import { Ranker } from 'blocks/utilities/ranker'; import presentationSettings from 'settings/presentation'; -// TODO: Add redirects for previous articles collections -// TODO: Only concern is the sitemap logic, but that can be extracted later or we can -// try the Astro plugin, I guess. -// As priority and changefreq are ignored by Google, the ranking of all pages -// won't be a concern and we can simplify the generation by a whole lot. export const collection = { name: 'Collection', fields: [ diff --git a/src/blocks/models/snippetPage.js b/src/blocks/models/snippetPage.js index 2820b90bd..f40453f7a 100644 --- a/src/blocks/models/snippetPage.js +++ b/src/blocks/models/snippetPage.js @@ -51,6 +51,6 @@ export const snippetPage = { }, }, scopes: { - published: page => page.snippet.published, + published: page => page.snippet.isPublished, }, }; diff --git a/src/blocks/utilities/content.js b/src/blocks/utilities/content.js index 9734afc4d..63cc7e4df 100644 --- a/src/blocks/utilities/content.js +++ b/src/blocks/utilities/content.js @@ -90,12 +90,9 @@ export class Content { logger.log(`Creating new snippet ${snippetName} in ${directoryName}...`); const { rawContentPath: contentPath } = pathSettings; - // TODO: Temporary change, move the content directory as needed const directoryPath = path.join( process.cwd(), contentPath, - 'sources', - '30code', 'snippets', directoryName ); diff --git a/src/blocks/writers/feedWriter.js b/src/blocks/writers/feedWriter.js index a2e00e211..7cadb3cb2 100644 --- a/src/blocks/writers/feedWriter.js +++ b/src/blocks/writers/feedWriter.js @@ -24,16 +24,16 @@ export class FeedWriter { const logger = new Logger('FeedWriter.write'); const template = handlebars.compile(fs.readFileSync(templatePath, 'utf-8')); const pages = Application.dataset - .getModel('Page') - .records.feedEligible.slice(0, 50); - logger.log(`Generating feed for top blog routes`); + .getModel('Snippet') + .records.listedByNew.slice(0, 50); + logger.log(`Generating feed for new snippet routes`); const feed = template({ nodes: pages.flatMap(s => ({ - title: s.data.title, - fullRoute: s.fullRoute, - description: s.context.pageDescription, - pubDate: new Date(s.data.dateModified).toUTCString(), + title: s.title, + fullRoute: `${websiteUrl}${s.slug}`, + description: s.seoDescription, + pubDate: new Date(s.dateModified).toUTCString(), })), websiteName, websiteDescription, diff --git a/src/scripts/build.js b/src/scripts/build.js index b8ca21a48..4f194c385 100644 --- a/src/scripts/build.js +++ b/src/scripts/build.js @@ -10,7 +10,6 @@ export const build = async () => { Application.AssetWriter.write(), Application.SearchIndexWriter.write(), Application.PageWriter.write(), - // TODO: Replace with Astro plugin Application.SitemapWriter.write(), Application.FeedWriter.write(), ]); From d44c03aa77bf827b06e4415622d27131c2cadd75 Mon Sep 17 00:00:00 2001 From: Angelos Chalaris Date: Sun, 7 May 2023 17:14:17 +0300 Subject: [PATCH 39/44] Update sitemap `changefreq` and `priority` are ignored by Google, so we can use constants. --- src/blocks/writers/sitemapWriter.js | 25 +++++++++++++++++++++++-- src/templates/sitemapTemplate.hbs | 6 +++++- 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/src/blocks/writers/sitemapWriter.js b/src/blocks/writers/sitemapWriter.js index 1eab2471c..f94c120a7 100644 --- a/src/blocks/writers/sitemapWriter.js +++ b/src/blocks/writers/sitemapWriter.js @@ -5,6 +5,7 @@ import { Application } from 'blocks/application'; const { Logger } = Application; +const { websiteUrl } = Application.settings; const outPath = `${Application.settings.paths.publicPath}/sitemap.xml`; const templatePath = 'src/templates/sitemapTemplate.hbs'; @@ -20,11 +21,31 @@ export class SitemapWriter { static write = async () => { const logger = new Logger('SitemapWriter.write'); const template = handlebars.compile(fs.readFileSync(templatePath, 'utf-8')); - const pages = Application.dataset.getModel('Page').records.indexable; + const snippetPages = Application.dataset + .getModel('SnippetPage') + .records.published.flatMap(page => `${websiteUrl}${page.slug}`); + const collectionPages = Application.dataset + .getModel('CollectionPage') + .records.flatMap(page => `${websiteUrl}${page.slug}`); + const collectionsPages = Application.dataset + .getModel('CollectionsPage') + .records.flatMap(page => `${websiteUrl}${page.slug}`); + const homePage = [`${websiteUrl}/`]; + const staticPages = ['/about', '/faq', '/cookies'].map( + page => `${websiteUrl}${page}` + ); + + const pages = [ + homePage, + ...collectionsPages, + ...snippetPages, + ...collectionPages, + ...staticPages, + ]; logger.log(`Generating sitemap for ${pages.length} routes`); const sitemap = template({ - nodes: pages.flatSelect('fullRoute', 'priority'), + nodes: pages, }); await writeFile(outPath, sitemap); diff --git a/src/templates/sitemapTemplate.hbs b/src/templates/sitemapTemplate.hbs index 5871be064..cca6f2351 100644 --- a/src/templates/sitemapTemplate.hbs +++ b/src/templates/sitemapTemplate.hbs @@ -1,6 +1,10 @@ {{# each nodes }} - {{ this.fullRoute }} daily {{ this.priority }} + + {{ this }} + daily + 1.0 + {{/ each }} From d6bdc1b60dbd89399e3fad7e11ce8fc721b5f59f Mon Sep 17 00:00:00 2001 From: Angelos Chalaris Date: Sun, 7 May 2023 17:15:36 +0300 Subject: [PATCH 40/44] Redirect removed pages --- public/_redirects | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/public/_redirects b/public/_redirects index fe9f70f9e..641b85006 100644 --- a/public/_redirects +++ b/public/_redirects @@ -40,10 +40,18 @@ # SORTED LISTING PAGE REDIRECTS: # # * Redirect alternative sorting listings to page 1 of the dedicated listing. +# * Redirect articles listings to page 1 of the relevant snippet listing. # ----------------------------------------------------------------------------- -/blog/n/* /articles/p/1 301! -/blog/e/* /articles/p/1 301! -/blog/a/* /articles/p/1 301! +/blog/n/* /list/p/1 301! +/blog/e/* /list/p/1 301! +/blog/a/* /list/p/1 301! +/blog/p/* /list/p/1 301! +/artiles/p/* /list/p/1 301! +/articles/t/css/* /css/p/1 301! +/articles/t/javascript/* /js/p/1 301! +/articles/t/python/* /python/p/1 301! +/articles/t/react/* /react/p/1 301! +/articles/t/webdev/* /c/web-development/:splat 301! /list/e/* /list/p/1 301! /list/a/* /list/p/1 301! /:lang/a/* /:lang/p/1 301! From ca2cc574ce328a31c38e51ab79564660af6f1317 Mon Sep 17 00:00:00 2001 From: Angelos Chalaris Date: Sun, 7 May 2023 17:35:04 +0300 Subject: [PATCH 41/44] Improve naming for collection pages --- src/blocks/models/collectionPage.js | 17 ++++++++++------- src/blocks/models/collectionsPage.js | 16 ++++++++++------ src/pages/[lang]/[...listing].astro | 25 +++++++++++++------------ 3 files changed, 33 insertions(+), 25 deletions(-) diff --git a/src/blocks/models/collectionPage.js b/src/blocks/models/collectionPage.js index e86122f64..1e46aa341 100644 --- a/src/blocks/models/collectionPage.js +++ b/src/blocks/models/collectionPage.js @@ -1,6 +1,7 @@ import { Schemer } from 'blocks/utilities/schemer'; import pathSettings from 'settings/paths'; +// TODO: We can move all pages under a directory, let's try that export const collectionPage = { name: 'CollectionPage', fields: [ @@ -20,14 +21,16 @@ export const collectionPage = { const collection = page.collection; const context = {}; - // TODO: These can have simpler names, update Astro, too context.slug = page.slug; - context.listingName = collection.name; - context.listingDescription = collection.description; - context.listingCover = `/${pathSettings.staticAssetPath}/splash/${collection.splash}`; - context.listingSublinks = collection.sublinks; context.pageDescription = collection.seoDescription; + context.collection = { + name: collection.name, + description: collection.description, + cover: `/${pathSettings.staticAssetPath}/splash/${collection.splash}`, + sublinks: collection.sublinks, + }; + const pageNumber = page.pageNumber; const totalPages = collection.pageCount; const baseUrl = collection.slug; @@ -64,7 +67,7 @@ export const collectionPage = { } : null; - context.snippetList = PreviewSerializer.serializeArray( + context.collectionItems = PreviewSerializer.serializeArray( page.snippets.toArray(), { type: 'snippet' } ); @@ -72,7 +75,7 @@ export const collectionPage = { context.structuredData = Schemer.generateListingData({ title: page.name, slug: page.slug, - items: context.snippetList, + items: context.collectionItems, }); return context; diff --git a/src/blocks/models/collectionsPage.js b/src/blocks/models/collectionsPage.js index fefc3d91d..468d50eb6 100644 --- a/src/blocks/models/collectionsPage.js +++ b/src/blocks/models/collectionsPage.js @@ -24,13 +24,17 @@ export const collectionPage = { ({ serializers: { PreviewSerializer } }) => page => { const context = {}; - // TODO: These can have simpler names, update Astro, too + context.slug = page.slug; - context.listingName = page.name; - context.listingDescription = page.description; - context.listingCover = `/${pathSettings.staticAssetPath}/splash/${page.splash}`; context.pageDescription = page.shortDescription; + context.collection = { + name: page.name, + description: page.description, + cover: `/${pathSettings.staticAssetPath}/splash/${page.splash}`, + sublinks: [], + }; + const pageNumber = page.pageNumber; const totalPages = page.pageCount; const baseUrl = page.baseSlug; @@ -67,7 +71,7 @@ export const collectionPage = { } : null; - context.snippetList = PreviewSerializer.serializeArray( + context.collectionItems = PreviewSerializer.serializeArray( page.collections.toArray(), { type: 'collection' } ); @@ -75,7 +79,7 @@ export const collectionPage = { context.structuredData = Schemer.generateListingData({ title: page.name, slug: page.slug, - items: context.snippetList, + items: context.collectionItems, }); return context; diff --git a/src/pages/[lang]/[...listing].astro b/src/pages/[lang]/[...listing].astro index 8e28cd121..9fee6c25b 100644 --- a/src/pages/[lang]/[...listing].astro +++ b/src/pages/[lang]/[...listing].astro @@ -33,27 +33,24 @@ export async function getStaticPaths() { const { slug, paginator = null, - snippetList, - listingName, - listingDescription, - listingSublinks = [], - listingCover, + collection, + collectionItems, pageDescription, structuredData, } = Astro.props; ---

    -

    {listingName}

    +

    {collection.name}

    - {listingDescription} + {collection.description}

    - {listingSublinks.length ? : null} + { + collection.sublinks.length ? ( + + ) : null + }
    - + {paginator ? : null}
    From 1e634a9e4085c7a89f205475d695fe57e6178057 Mon Sep 17 00:00:00 2001 From: Angelos Chalaris Date: Sun, 7 May 2023 17:36:33 +0300 Subject: [PATCH 42/44] Organize page models --- src/blocks/application.js | 6 +++--- src/blocks/models/{ => pages}/collectionPage.js | 1 - src/blocks/models/{ => pages}/collectionsPage.js | 0 src/blocks/models/{ => pages}/homePage.js | 0 src/blocks/models/{ => pages}/snippetPage.js | 0 5 files changed, 3 insertions(+), 4 deletions(-) rename src/blocks/models/{ => pages}/collectionPage.js (97%) rename src/blocks/models/{ => pages}/collectionsPage.js (100%) rename src/blocks/models/{ => pages}/homePage.js (100%) rename src/blocks/models/{ => pages}/snippetPage.js (100%) diff --git a/src/blocks/application.js b/src/blocks/application.js index 5ac6affba..d8b1a085f 100644 --- a/src/blocks/application.js +++ b/src/blocks/application.js @@ -190,9 +190,9 @@ export class Application { */ static loadModels() { const logger = new Logger('Application.loadModels'); - logger.log('Loading models from src/blocks/models/*.js...'); + logger.log('Loading models from src/blocks/models/**/*.js...'); Application._rawModels = glob - .sync(`src/blocks/models/*.js`) + .sync(`src/blocks/models/**/*.js`) .map(file => require(path.resolve(file))) .reduce((modules, module) => { // Note this only supports one export and will cause trouble otherwise @@ -201,7 +201,7 @@ export class Application { return modules; }, []); logger.success( - `Found and loaded ${Application._rawModels.length} models in src/blocks/models/*.js.` + `Found and loaded ${Application._rawModels.length} models in src/blocks/models/**/*.js.` ); } diff --git a/src/blocks/models/collectionPage.js b/src/blocks/models/pages/collectionPage.js similarity index 97% rename from src/blocks/models/collectionPage.js rename to src/blocks/models/pages/collectionPage.js index 1e46aa341..c5379e937 100644 --- a/src/blocks/models/collectionPage.js +++ b/src/blocks/models/pages/collectionPage.js @@ -1,7 +1,6 @@ import { Schemer } from 'blocks/utilities/schemer'; import pathSettings from 'settings/paths'; -// TODO: We can move all pages under a directory, let's try that export const collectionPage = { name: 'CollectionPage', fields: [ diff --git a/src/blocks/models/collectionsPage.js b/src/blocks/models/pages/collectionsPage.js similarity index 100% rename from src/blocks/models/collectionsPage.js rename to src/blocks/models/pages/collectionsPage.js diff --git a/src/blocks/models/homePage.js b/src/blocks/models/pages/homePage.js similarity index 100% rename from src/blocks/models/homePage.js rename to src/blocks/models/pages/homePage.js diff --git a/src/blocks/models/snippetPage.js b/src/blocks/models/pages/snippetPage.js similarity index 100% rename from src/blocks/models/snippetPage.js rename to src/blocks/models/pages/snippetPage.js From 9bce2a95008515755694a2d2f85d598ed6e1447c Mon Sep 17 00:00:00 2001 From: Angelos Chalaris Date: Sun, 7 May 2023 17:37:19 +0300 Subject: [PATCH 43/44] Remove unused fixtures --- src/test/fixtures/content.js | 744 ----------------------------------- 1 file changed, 744 deletions(-) delete mode 100644 src/test/fixtures/content.js diff --git a/src/test/fixtures/content.js b/src/test/fixtures/content.js deleted file mode 100644 index 33c4dc0f8..000000000 --- a/src/test/fixtures/content.js +++ /dev/null @@ -1,744 +0,0 @@ -// Mocked extractor output (.content/content.json) - -export const repo30blog = { - name: '30 seconds Blog', - repoUrl: 'https://github.com/30-seconds/30-seconds-blog', - slug: 'articles', - isBlog: true, - featured: true, - splash: 'laptop-view.png', - description: - 'The coding articles collection contains curated stories, tips, questions and answers on a wide variety of topics. The main focus of these articles revolves around the languages and technologies presented in snippets, as well as career advice and lessons.', - shortDescription: - 'Discover dozens of programming articles, covering a wide variety of topics and technologies.', - id: '30blog', - language: null, - icon: 'blog', -}; - -export const repo30code = { - name: '30 seconds of code', - repoUrl: 'https://github.com/30-seconds/30-seconds-of-code', - slug: 'js', - featured: true, - splash: 'laptop-plant.png', - description: - 'The JavaScript snippet collection contains a wide variety of ES6 helper functions. It includes helpers for dealing with primitives, arrays and objects, as well as algorithms, DOM manipulation functions and Node.js utilities.', - shortDescription: - 'Browse a wide variety of ES6 helper functions, including array operations, DOM manipulation, algorithms and Node.js utilities.', - id: '30code', - language: 'javascript', - icon: 'js', -}; - -export const repo30css = { - name: '30 seconds of CSS', - repoUrl: 'https://github.com/30-seconds/30-seconds-of-css', - slug: 'css', - featured: true, - splash: 'camera.png', - description: - 'The CSS snippet collection contains utilities and interactive examples for CSS3. It includes modern techniques for creating commonly-used layouts, styling and animating elements, as well as snippets for handling user interactions.', - shortDescription: - 'A snippet collection of interactive CSS3 examples, covering layouts, styling, animation and user interactions.', - id: '30css', - language: 'css', - icon: 'css', -}; - -export const repo30react = { - name: '30 seconds of React', - repoUrl: 'https://github.com/30-seconds/30-seconds-of-react', - slug: 'react', - featured: true, - splash: 'succulent-cluster.png', - description: - 'The React snippet collection contains function components and reusable hooks for React 16.', - shortDescription: - 'Discover function components and reusable hooks for React 16.', - id: '30react', - language: 'react', - icon: 'react', -}; - -export const repositories = [repo30blog, repo30code, repo30css, repo30react]; - -export const staticCollection = { - slug: 'c/react-rendering', - name: 'React Rendering', - featured: true, - splash: 'glasses-comic.png', - description: - 'Understanding of the rendering process is a crucial piece of knowledge when creating web applications with React. Take a deep dive into the fundamentals and core concepts as well as more advanced techniques with this series of articles.', - shortDescription: - "Understand the fundamentals of React's rendering process as well as more advanced techniques with this series of articles.", - snippetIds: [ - 'articles/s/react-rendering-basics', - 'articles/s/react-rendering-optimization', - 'react/s/use-interval', - ], - id: 'react-rendering', - icon: 'react', -}; - -export const dynamicCollection = { - slug: 'c/tips', - name: 'Tips & Tricks', - featured: true, - typeMatcher: 'tip', - splash: 'laptop-plant.png', - description: - 'Finding ways to improve and optimize your code takes a lot of time, research and energy. Level up your coding skills one step at a time with this collection of quick tips and tricks.', - shortDescription: - 'A collection of quick tips and tricks to level up your coding skills one step at a time.', - snippetIds: [], - id: 'tips', - icon: 'blog', -}; - -export const collections = [staticCollection, dynamicCollection]; - -// React rendering: [0, 1] -// Tips & Tricks: [2] -// CSS: [3] -// JavaScript: [4] -// Node.js: [5] -// Regular: [6] -export const blogSnippets = [ - { - id: 'articles/s/react-rendering-basics', - fileName: 'react-rendering-basics.md', - title: 'React rendering basics', - shortTitle: 'React rendering basics', - tags: ['react', 'render'], - dateModified: '2021-06-12T16:30:41.000Z', - listed: true, - type: 'story', - fullText: - "#### React rendering\n\n- React rendering basics (this blog post)\n- [React rendering optimization](/blog/s/react-rendering-optimization)\n- [React rendering state](/blog/s/react-rendering-state)\n\n\n### Rendering introduction\n\n**Rendering** is the process during which React moves down the component tree starting at the root, looking for all the components flagged for update, asking them to describe their desired UI structure based on the current combination of `props` and `state`. For each flagged component, React will call its `render()` method (for class components) or `FunctionComponent()` (for function components), and save the output produced after converting the JSX result into a plain JS object, using `React.createElement()`.\n\nAfter collecting the render output from the entire component tree, React will diff the new tree (the **virtual DOM**) with the current DOM tree and collect the list of changes that need to be made to the DOM to produce the desired UI structure. After this process, known as **reconciliation**, React applies all the calculated changes to the DOM.\n\n### Render and commit phases\n\nConceptually, this work is divided into two phases:\n\n- **Render phase**: rendering components, calculating changes\n- **Commit phase**: applying the changes to the DOM\n\nAfter the **commit phase** is complete, React will run `componentDidMount` and `componentDidUpdate` lifecycle methods, as well as `useLayoutEffect` and, after a short timeout, `useEffect` hooks.\n\nTwo key takeaways here are the following:\n\n- Rendering is not the same as updating the DOM\n- A component may be rendered without any visible changes\n\n### Rendering reasons\n\nAfter the initial render has completed, there are a few different things that will cause a re-render:\n\n- `this.setState()` (class components)\n- `this.forceUpdate()` (class components)\n- `useState()` setters (function components)\n- `useReducer()` dispatches (function components)\n- `ReactDOM.render()` again (on the root component)\n\n### Rendering behavior\n\nReact's default behavior is to **recursively render all child components inside of it when a parent component is rendered**. This means that it does not care if a component's `props` have changed - as long as the parent component rendered, its children will render unconditionally.\n\nTo put this another way, calling `setState()` in the root component without any other changes, will cause React to re-render every single component in the component tree. Most likely, most of the components will return the exact same render output as the last render, meaning React will not have to make any changes to the DOM, but the rendering and diffing calculations will be performed regardless, taking time and effort.\n\n[Continue on React rendering optimization](/blog/s/react-rendering-optimization)\n", - shortText: - "Take a deeper dive into React's rendering process and understand the basics behind the popular JavaScript framework.", - fullDescriptionHtml: - '

    React rendering

    \n\n

    Rendering introduction

    \n

    Rendering is the process during which React moves down the component tree starting at the root, looking for all the components flagged for update, asking them to describe their desired UI structure based on the current combination of props and state. For each flagged component, React will call its render() method (for class components) or FunctionComponent() (for function components), and save the output produced after converting the JSX result into a plain JS object, using React.createElement().

    \n

    After collecting the render output from the entire component tree, React will diff the new tree (the virtual DOM) with the current DOM tree and collect the list of changes that need to be made to the DOM to produce the desired UI structure. After this process, known as reconciliation, React applies all the calculated changes to the DOM.

    \n

    Render and commit phases

    \n

    Conceptually, this work is divided into two phases:

    \n
      \n
    • Render phase: rendering components, calculating changes
    • \n
    • Commit phase: applying the changes to the DOM
    • \n
    \n

    After the commit phase is complete, React will run componentDidMount and componentDidUpdate lifecycle methods, as well as useLayoutEffect and, after a short timeout, useEffect hooks.

    \n

    Two key takeaways here are the following:

    \n
      \n
    • Rendering is not the same as updating the DOM
    • \n
    • A component may be rendered without any visible changes
    • \n
    \n

    Rendering reasons

    \n

    After the initial render has completed, there are a few different things that will cause a re-render:

    \n
      \n
    • this.setState() (class components)
    • \n
    • this.forceUpdate() (class components)
    • \n
    • useState() setters (function components)
    • \n
    • useReducer() dispatches (function components)
    • \n
    • ReactDOM.render() again (on the root component)
    • \n
    \n

    Rendering behavior

    \n

    React\'s default behavior is to recursively render all child components inside of it when a parent component is rendered. This means that it does not care if a component\'s props have changed - as long as the parent component rendered, its children will render unconditionally.

    \n

    To put this another way, calling setState() in the root component without any other changes, will cause React to re-render every single component in the component tree. Most likely, most of the components will return the exact same render output as the last render, meaning React will not have to make any changes to the DOM, but the rendering and diffing calculations will be performed regardless, taking time and effort.

    \n

    Continue on React rendering optimization

    ', - descriptionHtml: - "

    Take a deeper dive into React's rendering process and understand the basics behind the popular JavaScript framework.

    ", - cover: 'blog_images/comic-glasses.jpg', - author: 'chalarangelo', - seoDescription: - "Take a deeper dive into React's rendering process and understand the basics behind the popular JavaScript framework.", - repository: '30blog', - }, - { - id: 'articles/s/react-rendering-optimization', - fileName: 'react-rendering-optimization.md', - title: 'React rendering optimization', - shortTitle: 'React rendering optimization', - tags: ['react', 'render'], - dateModified: '2021-06-12T16:30:41.000Z', - listed: true, - type: 'story', - fullText: - "#### React rendering\n\n- [React rendering basics](/blog/s/react-rendering-basics)\n- React rendering optimization (this blog post)\n- [React rendering state](/blog/s/react-rendering-state)\n\n### Optimization opportunities\n\nAs we've seen in the [previous blog post](/blog/s/react-rendering-basics), **rendering** is React's way of knowing if it needs to make changes in the DOM, but there are certain cases where work and calculations performed during the **render phase** can be a wasted effort. After all, if a component's render output is identical, there will be no DOM updates, thus the work wasn't necessary.\n\nRender output should always be based on the current combination of `props` and `state`, so it is possible to know ahead of time if a component's render output will be the same so long as its `props` and `state` remain unchanged. This is the key observation on top of which optimizing React rendering is based, as it hinges on our code doing less work and skipping component rendering when possible.\n\n### Optimization techniques\n\nReact offers a handful of APIs that allow us to optimize the rendering process:\n\n- `shouldComponentUpdate` (class components): Lifecycle method, called before rendering, returning a boolean (`false` to skip rendering, `true` to proceed as usual). Logic can vary as necessary, but the most common case is checking if the component's `props` and `state` have changed.\n- `React.PureComponent` (class components): Base class that implements the previously described `props` and `state` change check in its `shouldComponentUpdate` lifecycle method.\n- `React.memo()` (any component): Higher-order component (HOC) that wraps any given component. It implements the same kind of functionality as `React.PureComponent`, but can also wrap function components.\n\nAll of these techniques use **shallow equality** for comparisons. Skipping rendering a component means skipping the default recursive behavior of rendering children, effectively skipping the whole subtree of components.\n\n### Reference memoization\n\nPassing new references as `props` to a child component doesn't usually matter, as it will re-render regardless when the parent changes. However, if you are trying to optimize a child component's rendering by checking if its `props` have changed, passing new references will cause a render. This behavior is ok if the new references are updated data, but if it's a new reference to the same callback function passed down by the parent, it's rather problematic.\n\nThis is less of an issue in class components, as they have instance methods whose references don't change, although any sort of generated callbacks passed down to a component's children can result in new references. As far as function components are concerned, React provides the `useMemo` hook for memoizing values, and the `useCallback` hook specifically for memoizing callbacks.\n\n`useMemo` and `useCallback` can provide performance benefits but, as with any other memoization usage, it's important to think about their necessity and the net benefit they provide in the long run. A good rule of thumb is to consider using them for pure functional components that re-render often with the same `props` and/or might do heavy calculations and avoid them elsewhere.\n\n### Performance measurement\n\n**React Developer Tools** provide a handy **Profiler** tab that allows you to visualize and explore the rendering process of your React applications. Under this tab, you will find a settings icon which will allow you to _Highlight updates when components render_, as well as _Record why each component rendered while profiling_ - I highly suggest ticking both of them. Recording the initial render and re-renders of the website can provide invaluable insights about the application's bottlenecks and issues and also highlight optimization opportunities (often using one of the techniques described above).\n\nFinally, remember that React's development builds are significantly slower than production builds, so take all the measurements you see with a grain of salt as absolute times in development are not a valuable metric. Identifying unnecessary renders, memoization and optimization opportunities, as well as potential bottlenecks is where you should focus.\n\n[Continue on React rendering state](/blog/s/react-rendering-state)\n", - shortText: - "Take a deeper dive into React's rendering process and understand how to make small yet powerful tweaks to optimize performance.", - fullDescriptionHtml: - '

    React rendering

    \n\n

    Optimization opportunities

    \n

    As we\'ve seen in the previous blog post, rendering is React\'s way of knowing if it needs to make changes in the DOM, but there are certain cases where work and calculations performed during the render phase can be a wasted effort. After all, if a component\'s render output is identical, there will be no DOM updates, thus the work wasn\'t necessary.

    \n

    Render output should always be based on the current combination of props and state, so it is possible to know ahead of time if a component\'s render output will be the same so long as its props and state remain unchanged. This is the key observation on top of which optimizing React rendering is based, as it hinges on our code doing less work and skipping component rendering when possible.

    \n

    Optimization techniques

    \n

    React offers a handful of APIs that allow us to optimize the rendering process:

    \n
      \n
    • shouldComponentUpdate (class components): Lifecycle method, called before rendering, returning a boolean (false to skip rendering, true to proceed as usual). Logic can vary as necessary, but the most common case is checking if the component\'s props and state have changed.
    • \n
    • React.PureComponent (class components): Base class that implements the previously described props and state change check in its shouldComponentUpdate lifecycle method.
    • \n
    • React.memo() (any component): Higher-order component (HOC) that wraps any given component. It implements the same kind of functionality as React.PureComponent, but can also wrap function components.
    • \n
    \n

    All of these techniques use shallow equality for comparisons. Skipping rendering a component means skipping the default recursive behavior of rendering children, effectively skipping the whole subtree of components.

    \n

    Reference memoization

    \n

    Passing new references as props to a child component doesn\'t usually matter, as it will re-render regardless when the parent changes. However, if you are trying to optimize a child component\'s rendering by checking if its props have changed, passing new references will cause a render. This behavior is ok if the new references are updated data, but if it\'s a new reference to the same callback function passed down by the parent, it\'s rather problematic.

    \n

    This is less of an issue in class components, as they have instance methods whose references don\'t change, although any sort of generated callbacks passed down to a component\'s children can result in new references. As far as function components are concerned, React provides the useMemo hook for memoizing values, and the useCallback hook specifically for memoizing callbacks.

    \n

    useMemo and useCallback can provide performance benefits but, as with any other memoization usage, it\'s important to think about their necessity and the net benefit they provide in the long run. A good rule of thumb is to consider using them for pure functional components that re-render often with the same props and/or might do heavy calculations and avoid them elsewhere.

    \n

    Performance measurement

    \n

    React Developer Tools provide a handy Profiler tab that allows you to visualize and explore the rendering process of your React applications. Under this tab, you will find a settings icon which will allow you to Highlight updates when components render, as well as Record why each component rendered while profiling - I highly suggest ticking both of them. Recording the initial render and re-renders of the website can provide invaluable insights about the application\'s bottlenecks and issues and also highlight optimization opportunities (often using one of the techniques described above).

    \n

    Finally, remember that React\'s development builds are significantly slower than production builds, so take all the measurements you see with a grain of salt as absolute times in development are not a valuable metric. Identifying unnecessary renders, memoization and optimization opportunities, as well as potential bottlenecks is where you should focus.

    \n

    Continue on React rendering state

    ', - descriptionHtml: - "

    Take a deeper dive into React's rendering process and understand how to make small yet powerful tweaks to optimize performance.

    ", - cover: 'blog_images/comic-glasses.jpg', - author: 'chalarangelo', - seoDescription: - "Take a deeper dive into React's rendering process and understand how to make small yet powerful tweaks to optimize performance.", - repository: '30blog', - }, - { - id: 'articles/s/react-use-state-with-label', - fileName: 'react-use-state-with-label.md', - title: 'Tip: Label your useState values in React developer tools', - shortTitle: 'Label useState values in development', - tags: ['react', 'hooks'], - dateModified: '2021-11-07T13:34:37.000Z', - listed: true, - type: 'tip', - fullText: - "When working with multiple `useState` hooks in React, things can get a bit complicated while debugging. Luckily, there's an easy way to label these values, using the [`useDebugValue`](https://reactjs.org/docs/hooks-reference.html#usedebugvalue) hook to create a custom `useStateWithLabel` hook:\n\n```jsx\nconst useStateWithLabel = (initialValue, label) => {\n const [value, setValue] = useState(initialValue);\n useDebugValue(`${label}: ${value}`);\n return [value, setValue];\n};\n\nconst Counter = () => {\n const [value, setValue] = useStateWithLabel(0, 'counter');\n return (\n

    {value}

    \n );\n};\n\nReactDOM.render(, document.getElementById('root'));\n// Inspecting `Counter` in React developer tools will display:\n// StateWithLabel: \"counter: 0\"\n```\n\nThis hook is obviously meant mainly for development, but it can also be useful when creating React component or hook libraries. Additionally, you can easily abstract it in a way that the label is ignored in production builds. An example would be exporting a hook that defaults back to `useState` when building for a production environment.\n", - shortText: - "When working with multiple `useState` hooks in React, things can get a bit complicated while debugging. Luckily, there's an easy way to label these values.", - fullDescriptionHtml: - '

    When working with multiple useState hooks in React, things can get a bit complicated while debugging. Luckily, there\'s an easy way to label these values, using the useDebugValue hook to create a custom useStateWithLabel hook:

    \n
    const useStateWithLabel = (initialValue, label) => {\n  const [value, setValue] = useState(initialValue);\n  useDebugValue(`${label}: ${value}`);\n  return [value, setValue];\n};\n\nconst Counter = () => {\n  const [value, setValue] = useStateWithLabel(0, \'counter\');\n  return (\n    <p>{value}</p>\n  );\n};\n\nReactDOM.render(<Counter />, document.getElementById(\'root\'));\n// Inspecting `Counter` in React developer tools will display:\n//  StateWithLabel: "counter: 0"
    \n

    This hook is obviously meant mainly for development, but it can also be useful when creating React component or hook libraries. Additionally, you can easily abstract it in a way that the label is ignored in production builds. An example would be exporting a hook that defaults back to useState when building for a production environment.

    ', - descriptionHtml: - '

    When working with multiple useState hooks in React, things can get a bit complicated while debugging. Luckily, there\'s an easy way to label these values.

    ', - cover: 'blog_images/bunny-poster.jpg', - author: 'chalarangelo', - seoDescription: - "When working with multiple useState hooks in React, things can get a bit complicated while debugging. Luckily, there's an easy way to label these values.", - repository: '30blog', - }, - { - id: 'articles/s/responsive-favicon-dark-mode', - fileName: 'responsive-favicon-dark-mode.md', - title: 'How can I create a custom responsive favicon for dark mode?', - shortTitle: 'Custom responsive dark mode favicon', - tags: ['css', 'visual'], - dateModified: '2021-09-28T16:40:01.000Z', - listed: true, - type: 'question', - fullText: - 'The rise of dark mode in recent years has made many website favicons feel awkward or even impossible to see in some cases. Provided you have the appropriate assets, it\'s relatively easy to create a responsive favicon that can adapt to the user\'s color scheme preferences.\n\nIn order to create a responsive favicon, you need an SVG icon with as few colors as possible and two color palettes, one for light mode and one for dark mode. Usual rules about icon clarity and complexity apply, so make sure your icon meets all the necessary criteria to be visually distinguishable in any scenario. In our example, we will be using a monochrome icon from the fantastic [Feather icon set](https://feathericons.com/).\n\nLeveraging embedded styles in SVG images and the `prefers-color-scheme` media query, we can create an appropriate `` element to group all the elements of the icon. Then, using the `id` of the group, we can apply the color palette for each design. Here\'s what our final SVG asset looks like:\n\n```html\n\n \n \n \n \n \n \n\n```\n\nAfter creating the SVG asset, you need only include the custom SVG favicon in the page\'s `` element and you\'re ready to go. Be sure to include a PNG fallback, if possible, with a rendered version of the icon in either palette:\n\n```html\n\n \n \n \n \n\n```\n', - shortText: - 'Learn how to create a custom responsive favicon that can adapt its color palette for dark mode with this quick guide.', - fullDescriptionHtml: - '

    The rise of dark mode in recent years has made many website favicons feel awkward or even impossible to see in some cases. Provided you have the appropriate assets, it\'s relatively easy to create a responsive favicon that can adapt to the user\'s color scheme preferences.

    \n

    In order to create a responsive favicon, you need an SVG icon with as few colors as possible and two color palettes, one for light mode and one for dark mode. Usual rules about icon clarity and complexity apply, so make sure your icon meets all the necessary criteria to be visually distinguishable in any scenario. In our example, we will be using a monochrome icon from the fantastic Feather icon set.

    \n

    Leveraging embedded styles in SVG images and the prefers-color-scheme media query, we can create an appropriate <g> element to group all the elements of the icon. Then, using the id of the group, we can apply the color palette for each design. Here\'s what our final SVG asset looks like:

    \n
    <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">\n  <style>\n    #icon {\n      stroke: #000;\n      stroke-width: 2px;\n      stroke-linecap: round;\n      stroke-linejoin: round;\n      fill: none;\n    }\n\n    @media (prefers-color-scheme: dark) {\n      #icon {\n        stroke: #fff;\n      }\n    }\n  </style>\n  <g id="icon">\n    <path d="M6 2L3 6v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6l-3-4z"></path>\n    <line x1="3" y1="6" x2="21" y2="6"></line>\n    <path d="M16 10a4 4 0 0 1-8 0"></path>\n  </g>\n</svg>
    \n

    After creating the SVG asset, you need only include the custom SVG favicon in the page\'s <head> element and you\'re ready to go. Be sure to include a PNG fallback, if possible, with a rendered version of the icon in either palette:

    \n
    <head>\n  <!-- Provided you have a rendered PNG fallback named favicon.png -->\n  <link rel="icon" type="image/png" href="favicon.png" >\n  <!-- Provided the SVG icon is named favicon.svg -->\n  <link rel="icon" type="image/svg" href="favicon.svg" >\n</head>
    ', - descriptionHtml: - '

    Learn how to create a custom responsive favicon that can adapt its color palette for dark mode with this quick guide.

    ', - cover: 'blog_images/dark-mode.jpg', - author: 'chalarangelo', - seoDescription: - 'Learn how to create a custom responsive favicon that can adapt its color palette for dark mode with this quick guide.', - repository: '30blog', - }, - { - id: 'articles/s/js-callbacks', - fileName: 'js-callbacks.md', - title: 'What is a callback function?', - shortTitle: 'What is a callback function?', - tags: ['javascript', 'function'], - dateModified: '2021-10-03T09:00:00.000Z', - listed: true, - type: 'question', - fullText: - "A callback function is a function passed as an argument to another function, which is then invoked inside the outer function. Callback functions are often executed once an event has occurred or a task has completed.\n\n### Synchronous callbacks\n\nA synchronous callback is a callback function that is executed immediately. The function passed as the first argument to `Array.prototype.map()` is a great example of a synchronous callback:\n\n```js\nconst nums = [1, 2, 3];\nconst printDoublePlusOne = n => console.log(2 * n + 1);\n\nnums.map(printDoublePlusOne); // LOGS: 3, 5, 7\n```\n\n### Asynchronous callbacks\n\nAn asynchronous callback is a callback function that is used to execute code after an asynchronous operation has completed. The function executed inside `Promise.prototype.then()` is a great example of an asynchronous callback:\n\n```js\nconst nums = fetch('https://api.nums.org'); // Suppose the response is [1, 2, 3]\nconst printDoublePlusOne = n => console.log(2 * n + 1);\n\nnums.then(printDoublePlusOne); // LOGS: 3, 5, 7\n```\n", - shortText: - 'JavaScript uses callback functions in various places for different purposes. From event listeners to asynchronous operations, they are an invaluable tool you need to master.', - fullDescriptionHtml: - '

    A callback function is a function passed as an argument to another function, which is then invoked inside the outer function. Callback functions are often executed once an event has occurred or a task has completed.

    \n

    Synchronous callbacks

    \n

    A synchronous callback is a callback function that is executed immediately. The function passed as the first argument to Array.prototype.map() is a great example of a synchronous callback:

    \n
    const nums = [1, 2, 3];\nconst printDoublePlusOne = n => console.log(2 * n + 1);\n\nnums.map(printDoublePlusOne); // LOGS: 3, 5, 7
    \n

    Asynchronous callbacks

    \n

    An asynchronous callback is a callback function that is used to execute code after an asynchronous operation has completed. The function executed inside Promise.prototype.then() is a great example of an asynchronous callback:

    \n
    const nums = fetch(\'https://api.nums.org\'); // Suppose the response is [1, 2, 3]\nconst printDoublePlusOne = n => console.log(2 * n + 1);\n\nnums.then(printDoublePlusOne); // LOGS: 3, 5, 7
    ', - descriptionHtml: - '

    JavaScript uses callback functions in various places for different purposes. From event listeners to asynchronous operations, they are an invaluable tool you need to master.

    ', - cover: 'blog_images/rabbit-call.jpg', - author: 'chalarangelo', - seoDescription: - 'JavaScript uses callback functions in various places for different purposes. From event listeners to asynchronous operations, they are an invaluable tool you need to master.', - repository: '30blog', - }, - { - id: 'articles/s/nodejs-chrome-debugging', - fileName: 'nodejs-chrome-debugging.md', - title: 'Tip: Debugging Node.js using Chrome Developer Tools', - shortTitle: 'Debugging Node.js using Chrome Developer Tools', - tags: ['javascript', 'node', 'debugging'], - dateModified: '2021-06-12T16:30:41.000Z', - listed: true, - type: 'story', - fullText: - "Node.js can be debugged using Chrome Developer Tools since `v6.3.0`. Here's a quick guide on how to do this:\n\n1. Download and install Node.js `v6.3.0` or newer, if you don't already have it installed on your machine.\n2. Run node with the `--inspect-brk` flag (e.g. `node --inspect-brk index.js`).\n3. Open `about:inspect` in a new tab in Chrome. You should see something like the screenshot below.\n4. Click `Open dedicated DevTools for Node` to open a new window connected to your Node.js instance.\n5. Use the Developer Tools to debug your Node.js application!\n\n![about:inspect page](./blog_images/chrome-debug-node.png)\n", - shortText: - 'Did you know you can use Chrome Developer Tools to debug your Node.js code? Find out how in this short guide.', - fullDescriptionHtml: - '

    Node.js can be debugged using Chrome Developer Tools since v6.3.0. Here\'s a quick guide on how to do this:

    \n
      \n
    1. Download and install Node.js v6.3.0 or newer, if you don\'t already have it installed on your machine.
    2. \n
    3. Run node with the --inspect-brk flag (e.g. node --inspect-brk index.js).
    4. \n
    5. Open about:inspect in a new tab in Chrome. You should see something like the screenshot below.
    6. \n
    7. Click Open dedicated DevTools for Node to open a new window connected to your Node.js instance.
    8. \n
    9. Use the Developer Tools to debug your Node.js application!
    10. \n
    \n\n \n about:inspect page\n ', - descriptionHtml: - '

    Did you know you can use Chrome Developer Tools to debug your Node.js code? Find out how in this short guide.

    ', - cover: 'blog_images/bug.jpg', - author: 'chalarangelo', - seoDescription: - 'Did you know you can use Chrome Developer Tools to debug your Node.js code? Find out how in this short guide.', - repository: '30blog', - }, - { - id: 'articles/s/10-vs-code-extensions-for-js-developers', - fileName: '10-vs-code-extensions-for-js-developers.md', - title: '10 must-have VS Code extensions for JavaScript developers', - shortTitle: 'VS Code extensions for JavaScript developers', - tags: ['devtools', 'vscode'], - dateModified: '2021-06-12T16:30:41.000Z', - listed: true, - type: 'list', - fullText: - "Developers will most likely argue for the rest of eternity about the most productive code editor and the best extensions. Here are my personal extension preferences for VS Code as a JavaScript developer:\n\n1. ESLint\n[ESLint](https://marketplace.visualstudio.com/items?itemName=dbaeumer.vscode-eslint) turns the popular JavaScript linter into an extension of VS Code. It automatically reads your linting configuration, identifies problems and even fixes them for you, if you want.\n\n2. GitLens\n[GitLens](https://marketplace.visualstudio.com/items?itemName=eamodio.gitlens) is a very powerful collaboration tool for VS Code. It provides many useful tools for git such as blame, code authorship, activity heatmaps, recent changes, file history and even commit search.\n\n3. Debugger for Chrome\n[Debugger for Chrome](https://marketplace.visualstudio.com/items?itemName=msjsdiag.debugger-for-chrome) allows you to debug your JavaScript code in Chrome or Chromium. Breakpoints, call stack inspection and stepping inside a function are only some of its features.\n\n4. Bracket Pair Colorizer 2\n[Bracket Pair Colorizer 2](https://marketplace.visualstudio.com/items?itemName=CoenraadS.bracket-pair-colorizer-2) makes reading code faster as it makes matching brackets the same color. This extension for VS Code improves upon its predecessor by providing improved performance.\n\n5. Bookmarks\n[Bookmarks](https://marketplace.visualstudio.com/items?itemName=alefragnani.Bookmarks) is one of those extensions that will significantly reduce your time jumping between different files, as it allows you to save important positions and navigate back to them easily and quickly.\n\n6. TODO Highlight\n[TODO Highlight](https://marketplace.visualstudio.com/items?itemName=wayou.vscode-todo-highlight) simplifies tracking leftover tasks by allowing you to list all of your TODO annotations, as well as adding a handy background highlight to them to make them pop out immediately.\n\n7. Live Server\n[Live Server](https://marketplace.visualstudio.com/items?itemName=ritwickdey.LiveServer) gives you an easy way to serve web pages from VS Code, making previewing and debugging a lot easier. One of the core features is the live reload support that many developers are used to.\n\n8. REST Client\n[REST Client](https://marketplace.visualstudio.com/items?itemName=humao.rest-client) allows you to send HTTP requests and view the responses directly in VS Code. This extension supports a wide range of formats and authorization and should work with most setups.\n\n9. One Dark Pro\n[One Dark Pro](https://marketplace.visualstudio.com/items?itemName=zhuangtongfa.Material-theme) is one of the most popular VS Code themes and with very good reason. It provides a clean theme with a nice palette that has great contrast and is very comfortable to use on a daily basis.\n\n10. Fira Code\n[Fira Code](https://github.com/tonsky/FiraCode) is not a traditional VS Code extension and might take a couple more steps to set up, but it's a superb programming font with ligatures that will help you scan code faster once you get used to it.\n", - shortText: - 'VS Code is steadily gaining popularity among developers. Here are 10 essential extensions for JavaScript developers that aim to increase your productivity.', - fullDescriptionHtml: - '

    Developers will most likely argue for the rest of eternity about the most productive code editor and the best extensions. Here are my personal extension preferences for VS Code as a JavaScript developer:

    \n
      \n
    1. ESLint
    2. \n
    \n

    ESLint turns the popular JavaScript linter into an extension of VS Code. It automatically reads your linting configuration, identifies problems and even fixes them for you, if you want.

    \n
      \n
    1. GitLens
    2. \n
    \n

    GitLens is a very powerful collaboration tool for VS Code. It provides many useful tools for git such as blame, code authorship, activity heatmaps, recent changes, file history and even commit search.

    \n
      \n
    1. Debugger for Chrome
    2. \n
    \n

    Debugger for Chrome allows you to debug your JavaScript code in Chrome or Chromium. Breakpoints, call stack inspection and stepping inside a function are only some of its features.

    \n
      \n
    1. Bracket Pair Colorizer 2
    2. \n
    \n

    Bracket Pair Colorizer 2 makes reading code faster as it makes matching brackets the same color. This extension for VS Code improves upon its predecessor by providing improved performance.

    \n
      \n
    1. Bookmarks
    2. \n
    \n

    Bookmarks is one of those extensions that will significantly reduce your time jumping between different files, as it allows you to save important positions and navigate back to them easily and quickly.

    \n
      \n
    1. TODO Highlight
    2. \n
    \n

    TODO Highlight simplifies tracking leftover tasks by allowing you to list all of your TODO annotations, as well as adding a handy background highlight to them to make them pop out immediately.

    \n
      \n
    1. Live Server
    2. \n
    \n

    Live Server gives you an easy way to serve web pages from VS Code, making previewing and debugging a lot easier. One of the core features is the live reload support that many developers are used to.

    \n
      \n
    1. REST Client
    2. \n
    \n

    REST Client allows you to send HTTP requests and view the responses directly in VS Code. This extension supports a wide range of formats and authorization and should work with most setups.

    \n
      \n
    1. One Dark Pro
    2. \n
    \n

    One Dark Pro is one of the most popular VS Code themes and with very good reason. It provides a clean theme with a nice palette that has great contrast and is very comfortable to use on a daily basis.

    \n
      \n
    1. Fira Code
    2. \n
    \n

    Fira Code is not a traditional VS Code extension and might take a couple more steps to set up, but it\'s a superb programming font with ligatures that will help you scan code faster once you get used to it.

    ', - descriptionHtml: - '

    VS Code is steadily gaining popularity among developers. Here are 10 essential extensions for JavaScript developers that aim to increase your productivity.

    ', - cover: 'blog_images/computer-screens.jpg', - author: 'chalarangelo', - seoDescription: - 'VS Code is steadily gaining popularity among developers. Here are 10 essential extensions for JavaScript developers that aim to increase your productivity.', - repository: '30blog', - }, -]; - -// Regular: [0, 1, 2] -// Node.js: [3] -export const codeSnippets = [ - { - id: 'js/s/format-duration', - fileName: 'formatDuration.md', - title: 'formatDuration', - shortTitle: 'formatDuration', - tags: ['date', 'math', 'string'], - dateModified: '2020-10-22T17:23:47.000Z', - listed: true, - type: 'snippet', - fullText: - "Returns the human-readable format of the given number of milliseconds.\n\n- Divide `ms` with the appropriate values to obtain the appropriate values for `day`, `hour`, `minute`, `second` and `millisecond`.\n- Use `Object.entries()` with `Array.prototype.filter()` to keep only non-zero values.\n- Use `Array.prototype.map()` to create the string for each value, pluralizing appropriately.\n- Use `String.prototype.join(', ')` to combine the values into a string.\n\n", - shortText: - 'Returns the human-readable format of the given number of milliseconds.', - - fullDescriptionHtml: - '

    Returns the human-readable format of the given number of milliseconds.

    \n
      \n
    • Divide ms with the appropriate values to obtain the appropriate values for day, hour, minute, second and millisecond.
    • \n
    • Use Object.entries() with Array.prototype.filter() to keep only non-zero values.
    • \n
    • Use Array.prototype.map() to create the string for each value, pluralizing appropriately.
    • \n
    • Use String.prototype.join(', ') to combine the values into a string.
    • \n
    ', - descriptionHtml: - '

    Returns the human-readable format of the given number of milliseconds.

    ', - srcCodeBlockHtml: - 'const formatDuration = ms => {\n if (ms < 0) ms = -ms;\n const time = {\n day: Math.floor(ms / 86400000),\n hour: Math.floor(ms / 3600000) % 24,\n minute: Math.floor(ms / 60000) % 60,\n second: Math.floor(ms / 1000) % 60,\n millisecond: Math.floor(ms) % 1000\n };\n return Object.entries(time)\n .filter(val => val[1] !== 0)\n .map(([key, val]) => `${val} ${key}${val !== 1 ? \'s\' : \'\'}`)\n .join(\', \');\n};', - exampleCodeBlockHtml: - 'formatDuration(1001); // \'1 second, 1 millisecond\'\nformatDuration(34325055574);\n// \'397 days, 6 hours, 44 minutes, 15 seconds, 574 milliseconds\'', - - srcCode: - "const formatDuration = ms => {\n if (ms < 0) ms = -ms;\n const time = {\n day: Math.floor(ms / 86400000),\n hour: Math.floor(ms / 3600000) % 24,\n minute: Math.floor(ms / 60000) % 60,\n second: Math.floor(ms / 1000) % 60,\n millisecond: Math.floor(ms) % 1000\n };\n return Object.entries(time)\n .filter(val => val[1] !== 0)\n .map(([key, val]) => `${val} ${key}${val !== 1 ? 's' : ''}`)\n .join(', ');\n};", - exampleCode: - "formatDuration(1001); // '1 second, 1 millisecond'\nformatDuration(34325055574);\n// '397 days, 6 hours, 44 minutes, 15 seconds, 574 milliseconds'", - author: 'oscc', - seoDescription: - 'Returns the human-readable format of the given number of milliseconds.', - repository: '30code', - }, - { - id: 'js/s/format-number', - fileName: 'formatNumber.md', - title: 'formatNumber', - shortTitle: 'formatNumber', - tags: ['string', 'math'], - dateModified: '2020-10-22T17:23:47.000Z', - listed: true, - type: 'snippet', - fullText: - 'Formats a number using the local number format order.\n\n- Use `Number.prototype.toLocaleString()` to convert a number to using the local number format separators.\n\n', - shortText: 'Formats a number using the local number format order.', - - fullDescriptionHtml: - '

    Formats a number using the local number format order.

    \n
      \n
    • Use Number.prototype.toLocaleString() to convert a number to using the local number format separators.
    • \n
    ', - descriptionHtml: - '

    Formats a number using the local number format order.

    ', - srcCodeBlockHtml: - 'const formatNumber = num => num.toLocaleString();', - exampleCodeBlockHtml: - 'formatNumber(123456); // \'123,456\' in `en-US`\nformatNumber(15675436903); // \'15.675.436.903\' in `de-DE`', - - srcCode: 'const formatNumber = num => num.toLocaleString();', - exampleCode: - "formatNumber(123456); // '123,456' in `en-US`\nformatNumber(15675436903); // '15.675.436.903' in `de-DE`", - author: 'oscc', - seoDescription: 'Formats a number using the local number format order.', - repository: '30code', - }, - { - id: 'js/s/format-seconds', - fileName: 'formatSeconds.md', - title: 'formatSeconds', - shortTitle: 'formatSeconds', - tags: ['date', 'math', 'string'], - dateModified: '2021-10-13T17:29:39.000Z', - listed: true, - type: 'snippet', - fullText: - "Returns the ISO format of the given number of seconds.\n\n- Divide `s` with the appropriate values to obtain the appropriate values for `hour`, `minute` and `second`.\n- Store the `sign` in a variable to prepend it to the result.\n- Use `Array.prototype.map()` in combination with `Math.floor()` and `String.prototype.padStart()` to stringify and format each segment.\n- Use `String.prototype.join(':')` to combine the values into a string.\n\n", - shortText: 'Returns the ISO format of the given number of seconds.', - - fullDescriptionHtml: - '

    Returns the ISO format of the given number of seconds.

    \n
      \n
    • Divide s with the appropriate values to obtain the appropriate values for hour, minute and second.
    • \n
    • Store the sign in a variable to prepend it to the result.
    • \n
    • Use Array.prototype.map() in combination with Math.floor() and String.prototype.padStart() to stringify and format each segment.
    • \n
    • Use String.prototype.join(':') to combine the values into a string.
    • \n
    ', - descriptionHtml: - '

    Returns the ISO format of the given number of seconds.

    ', - srcCodeBlockHtml: - 'const formatSeconds = s => {\n const [hour, minute, second, sign] =\n s > 0\n ? [s / 3600, (s / 60) % 60, s % 60, \'\']\n : [-s / 3600, (-s / 60) % 60, -s % 60, \'-\'];\n\n return (\n sign +\n [hour, minute, second]\n .map(v => `${Math.floor(v)}`.padStart(2, \'0\'))\n .join(\':\')\n );\n};', - exampleCodeBlockHtml: - 'formatSeconds(200); // \'00:03:20\'\nformatSeconds(-200); // \'-00:03:20\'\nformatSeconds(99999); // \'27:46:39\'', - - srcCode: - "const formatSeconds = s => {\n const [hour, minute, second, sign] =\n s > 0\n ? [s / 3600, (s / 60) % 60, s % 60, '']\n : [-s / 3600, (-s / 60) % 60, -s % 60, '-'];\n\n return (\n sign +\n [hour, minute, second]\n .map(v => `${Math.floor(v)}`.padStart(2, '0'))\n .join(':')\n );\n};", - exampleCode: - "formatSeconds(200); // '00:03:20'\nformatSeconds(-200); // '-00:03:20'\nformatSeconds(99999); // '27:46:39'", - author: 'oscc', - seoDescription: 'Returns the ISO format of the given number of seconds.', - repository: '30code', - }, - { - id: 'js/s/hash-node', - fileName: 'hashNode.md', - title: 'hashNode', - shortTitle: 'hashNode', - tags: ['node', 'promise'], - dateModified: '2021-10-13T17:29:39.000Z', - listed: true, - type: 'snippet', - fullText: - 'Creates a hash for a value using the [SHA-256](https://en.wikipedia.org/wiki/SHA-2) algorithm.\nReturns a promise.\n\n- Use `crypto.createHash()` to create a `Hash` object with the appropriate algorithm.\n- Use `hash.update()` to add the data from `val` to the `Hash`, `hash.digest()` to calculate the digest of the data.\n- Use `setTimeout()` to prevent blocking on a long operation. Return a `Promise` to give it a familiar interface.\n\n', - shortText: - 'Creates a hash for a value using the [SHA-256](https://en.wikipedia.org/wiki/SHA-2) algorithm.\nReturns a promise.', - - fullDescriptionHtml: - '

    Creates a hash for a value using the SHA-256 algorithm.\nReturns a promise.

    \n
      \n
    • Use crypto.createHash() to create a Hash object with the appropriate algorithm.
    • \n
    • Use hash.update() to add the data from val to the Hash, hash.digest() to calculate the digest of the data.
    • \n
    • Use setTimeout() to prevent blocking on a long operation. Return a Promise to give it a familiar interface.
    • \n
    ', - descriptionHtml: - '

    Creates a hash for a value using the SHA-256 algorithm.\nReturns a promise.

    ', - srcCodeBlockHtml: - 'const crypto = require(\'crypto\');\n\nconst hashNode = val =>\n new Promise(resolve =>\n setTimeout(\n () => resolve(crypto.createHash(\'sha256\').update(val).digest(\'hex\')),\n 0\n )\n );', - exampleCodeBlockHtml: - 'hashNode(JSON.stringify({ a: \'a\', b: [1, 2, 3, 4], foo: { c: \'bar\' } })).then(\n console.log\n);\n// \'04aa106279f5977f59f9067fa9712afc4aedc6f5862a8defc34552d8c7206393\'', - - srcCode: - "const crypto = require('crypto');\n\nconst hashNode = val =>\n new Promise(resolve =>\n setTimeout(\n () => resolve(crypto.createHash('sha256').update(val).digest('hex')),\n 0\n )\n );", - exampleCode: - "hashNode(JSON.stringify({ a: 'a', b: [1, 2, 3, 4], foo: { c: 'bar' } })).then(\n console.log\n);\n// '04aa106279f5977f59f9067fa9712afc4aedc6f5862a8defc34552d8c7206393'", - author: 'oscc', - seoDescription: - 'Creates a hash for a value using the SHA-256 algorithm.Returns a promise.', - repository: '30code', - }, -]; - -// Regular: [0] -// With JavaScript: [1] -export const cssSnippets = [ - { - id: 'css/s/triangle', - fileName: 'triangle.md', - title: 'Triangle', - shortTitle: 'Triangle', - tags: ['visual'], - dateModified: '2021-10-13T17:29:39.000Z', - listed: true, - type: 'snippet', - fullText: - 'Creates a triangular shape with pure CSS.\n\n- Use three borders to create a triangle shape.\n- All borders should have the same `border-width` (`20px`).\n- The opposite side of where the triangle points towards (i.e. top if the triangle points downwards) should have the desired `border-color`. The adjacent borders (i.e. left and right) should have a `border-color` of `transparent`.\n- Altering the `border-width` values will change the proportions of the triangle.\n\n', - shortText: 'Creates a triangular shape with pure CSS.', - - fullDescriptionHtml: - '

    Creates a triangular shape with pure CSS.

    \n
      \n
    • Use three borders to create a triangle shape.
    • \n
    • All borders should have the same border-width (20px).
    • \n
    • The opposite side of where the triangle points towards (i.e. top if the triangle points downwards) should have the desired border-color. The adjacent borders (i.e. left and right) should have a border-color of transparent.
    • \n
    • Altering the border-width values will change the proportions of the triangle.
    • \n
    ', - descriptionHtml: '

    Creates a triangular shape with pure CSS.

    ', - html: '<div class="triangle"></div>', - cssCodeBlockHtml: - '.triangle {\n width: 0;\n height: 0;\n border-top: 20px solid #9C27B0;\n border-left: 20px solid transparent;\n border-right: 20px solid transparent;\n}', - - htmlCode: '
    ', - cssCode: - '.triangle {\n width: 0;\n height: 0;\n border-top: 20px solid #9C27B0;\n border-left: 20px solid transparent;\n border-right: 20px solid transparent;\n}', - author: 'oscc', - seoDescription: 'Creates a triangular shape with pure CSS.', - repository: '30css', - }, - { - id: 'css/s/mouse-cursor-gradient-tracking', - fileName: 'mouse-cursor-gradient-tracking.md', - title: 'Mouse cursor gradient tracking', - shortTitle: 'Mouse cursor gradient tracking', - tags: ['visual', 'interactivity'], - dateModified: '2021-01-07T21:52:15.000Z', - listed: true, - type: 'snippet', - fullText: - "A hover effect where the gradient follows the mouse cursor.\n\n- Declare two CSS variables, `--x` and `--y`, used to track the position of the mouse on the button.\n- Declare a CSS variable, `--size`, used to modify the gradient's dimensions.\n- Use `background: radial-gradient(circle closest-side, pink, transparent);` to create the gradient at the correct position.\n- Use `Document.querySelector()` and `EventTarget.addEventListener()` to register a handler for the `'mousemove'` event.\n- Use `Element.getBoundingClientRect()` and `CSSStyleDeclaration.setProperty()` to update the values of the `--x` and `--y` CSS variables.\n\n", - shortText: 'A hover effect where the gradient follows the mouse cursor.', - - fullDescriptionHtml: - '

    A hover effect where the gradient follows the mouse cursor.

    \n
      \n
    • Declare two CSS variables, --x and --y, used to track the position of the mouse on the button.
    • \n
    • Declare a CSS variable, --size, used to modify the gradient\'s dimensions.
    • \n
    • Use background: radial-gradient(circle closest-side, pink, transparent); to create the gradient at the correct position.
    • \n
    • Use Document.querySelector() and EventTarget.addEventListener() to register a handler for the 'mousemove' event.
    • \n
    • Use Element.getBoundingClientRect() and CSSStyleDeclaration.setProperty() to update the values of the --x and --y CSS variables.
    • \n
    ', - descriptionHtml: - '

    A hover effect where the gradient follows the mouse cursor.

    ', - html: '<button class="mouse-cursor-gradient-tracking">\n <span>Hover me</span>\n</button>', - cssCodeBlockHtml: - '.mouse-cursor-gradient-tracking {\n position: relative;\n background: #7983ff;\n padding: 0.5rem 1rem;\n font-size: 1.2rem;\n border: none;\n color: white;\n cursor: pointer;\n outline: none;\n overflow: hidden;\n}\n\n.mouse-cursor-gradient-tracking span {\n position: relative;\n}\n\n.mouse-cursor-gradient-tracking:before {\n --size: 0;\n content: \'\';\n position: absolute;\n left: var(--x);\n top: var(--y);\n width: var(--size);\n height: var(--size);\n background: radial-gradient(circle closest-side, pink, transparent);\n transform: translate(-50%, -50%);\n transition: width 0.2s ease, height 0.2s ease;\n}\n\n.mouse-cursor-gradient-tracking:hover:before {\n --size: 200px;\n}', - js: 'let btn = document.querySelector(\'.mouse-cursor-gradient-tracking\');\nbtn.addEventListener(\'mousemove\', e => {\n let rect = e.target.getBoundingClientRect();\n let x = e.clientX - rect.left;\n let y = e.clientY - rect.top;\n btn.style.setProperty(\'--x\', x + \'px\');\n btn.style.setProperty(\'--y\', y + \'px\');\n});', - - htmlCode: - '', - cssCode: - ".mouse-cursor-gradient-tracking {\n position: relative;\n background: #7983ff;\n padding: 0.5rem 1rem;\n font-size: 1.2rem;\n border: none;\n color: white;\n cursor: pointer;\n outline: none;\n overflow: hidden;\n}\n\n.mouse-cursor-gradient-tracking span {\n position: relative;\n}\n\n.mouse-cursor-gradient-tracking:before {\n --size: 0;\n content: '';\n position: absolute;\n left: var(--x);\n top: var(--y);\n width: var(--size);\n height: var(--size);\n background: radial-gradient(circle closest-side, pink, transparent);\n transform: translate(-50%, -50%);\n transition: width 0.2s ease, height 0.2s ease;\n}\n\n.mouse-cursor-gradient-tracking:hover:before {\n --size: 200px;\n}", - jsCode: - "let btn = document.querySelector('.mouse-cursor-gradient-tracking');\nbtn.addEventListener('mousemove', e => {\n let rect = e.target.getBoundingClientRect();\n let x = e.clientX - rect.left;\n let y = e.clientY - rect.top;\n btn.style.setProperty('--x', x + 'px');\n btn.style.setProperty('--y', y + 'px');\n});", - author: 'oscc', - seoDescription: - 'A hover effect where the gradient follows the mouse cursor.', - repository: '30css', - }, -]; - -// Regular: [0] -// With CSS: [1] -export const reactSnippets = [ - { - id: 'react/s/use-interval', - fileName: 'useInterval.md', - title: 'useInterval', - shortTitle: 'useInterval', - tags: ['hooks', 'effect'], - dateModified: '2020-11-16T12:17:53.000Z', - listed: true, - type: 'snippet', - fullText: - 'Implements `setInterval` in a declarative manner.\n\n- Create a custom hook that takes a `callback` and a `delay`.\n- Use the `useRef()` hook to create a `ref` for the callback function.\n- Use a `useEffect()` hook to remember the latest `callback` whenever it changes.\n- Use a `useEffect()` hook dependent on `delay` to set up the interval and clean up.\n\n', - shortText: 'Implements `setInterval` in a declarative manner.', - - fullDescriptionHtml: - '

    Implements setInterval in a declarative manner.

    \n
      \n
    • Create a custom hook that takes a callback and a delay.
    • \n
    • Use the useRef() hook to create a ref for the callback function.
    • \n
    • Use a useEffect() hook to remember the latest callback whenever it changes.
    • \n
    • Use a useEffect() hook dependent on delay to set up the interval and clean up.
    • \n
    ', - descriptionHtml: - '

    Implements setInterval in a declarative manner.

    ', - srcCodeBlockHtml: - 'const useInterval = (callback, delay) => {\n const savedCallback = React.useRef();\n\n React.useEffect(() => {\n savedCallback.current = callback;\n }, [callback]);\n\n React.useEffect(() => {\n const tick = () => {\n savedCallback.current();\n }\n if (delay !== null) {\n let id = setInterval(tick, delay);\n return () => clearInterval(id);\n }\n }, [delay]);\n};', - exampleCodeBlockHtml: - 'const Timer = props => {\n const [seconds, setSeconds] = React.useState(0);\n useInterval(() => {\n setSeconds(seconds + 1);\n }, 1000);\n\n return <p>{seconds}</p>;\n};\n\nReactDOM.render(<Timer />, document.getElementById(\'root\'));', - - styleCode: '', - srcCode: - 'const useInterval = (callback, delay) => {\n const savedCallback = React.useRef();\n\n React.useEffect(() => {\n savedCallback.current = callback;\n }, [callback]);\n\n React.useEffect(() => {\n const tick = () => {\n savedCallback.current();\n }\n if (delay !== null) {\n let id = setInterval(tick, delay);\n return () => clearInterval(id);\n }\n }, [delay]);\n};', - exampleCode: - "const Timer = props => {\n const [seconds, setSeconds] = React.useState(0);\n useInterval(() => {\n setSeconds(seconds + 1);\n }, 1000);\n\n return

    {seconds}

    ;\n};\n\nReactDOM.render(, document.getElementById('root'));", - author: 'oscc', - seoDescription: 'Implements setInterval in a declarative manner.', - repository: '30react', - }, - { - id: 'react/s/tag-input', - fileName: 'TagInput.md', - title: 'TagInput', - shortTitle: 'TagInput', - tags: ['components', 'input', 'state'], - dateModified: '2020-11-25T19:12:16.000Z', - listed: true, - type: 'snippet', - fullText: - 'Renders a tag input field.\n\n- Define a `TagInput` component and use the `useState()` hook to initialize an array from `tags`.\n- Use `Array.prototype.map()` on the collected nodes to render the list of tags.\n- Define the `addTagData` method, which will be executed when pressing the `Enter` key.\n- The `addTagData` method calls `setTagData` to add the new tag using the spread (`...`) operator to prepend the existing tags and add the new tag at the end of the `tagData` array.\n- Define the `removeTagData` method, which will be executed on clicking the delete icon in the tag.\n- Use `Array.prototype.filter()` in the `removeTagData` method to remove the tag using its `index` to filter it out from the `tagData` array.\n\n', - shortText: 'Renders a tag input field.', - fullDescriptionHtml: - '

    Renders a tag input field.

    \n
      \n
    • Define a TagInput component and use the useState() hook to initialize an array from tags.
    • \n
    • Use Array.prototype.map() on the collected nodes to render the list of tags.
    • \n
    • Define the addTagData method, which will be executed when pressing the Enter key.
    • \n
    • The addTagData method calls setTagData to add the new tag using the spread (...) operator to prepend the existing tags and add the new tag at the end of the tagData array.
    • \n
    • Define the removeTagData method, which will be executed on clicking the delete icon in the tag.
    • \n
    • Use Array.prototype.filter() in the removeTagData method to remove the tag using its index to filter it out from the tagData array.
    • \n
    ', - descriptionHtml: '

    Renders a tag input field.

    ', - styleCodeBlockHtml: - '.tag-input {\n display: flex;\n flex-wrap: wrap;\n min-height: 48px;\n padding: 0 8px;\n border: 1px solid #d6d8da;\n border-radius: 6px;\n}\n\n.tag-input input {\n flex: 1;\n border: none;\n height: 46px;\n font-size: 14px;\n padding: 4px 0 0;\n}\n\n.tag-input input:focus {\n outline: transparent;\n}\n\n.tags {\n display: flex;\n flex-wrap: wrap;\n padding: 0;\n margin: 8px 0 0;\n}\n\n.tag {\n width: auto;\n height: 32px;\n display: flex;\n align-items: center;\n justify-content: center;\n color: #fff;\n padding: 0 8px;\n font-size: 14px;\n list-style: none;\n border-radius: 6px;\n margin: 0 8px 8px 0;\n background: #0052cc;\n}\n\n.tag-title {\n margin-top: 3px;\n}\n\n.tag-close-icon {\n display: block;\n width: 16px;\n height: 16px;\n line-height: 16px;\n text-align: center;\n font-size: 14px;\n margin-left: 8px;\n color: #0052cc;\n border-radius: 50%;\n background: #fff;\n cursor: pointer;\n}', - srcCodeBlockHtml: - 'const TagInput = ({ tags }) => {\n const [tagData, setTagData] = React.useState(tags);\n const removeTagData = indexToRemove => {\n setTagData([...tagData.filter((_, index) => index !== indexToRemove)]);\n };\n const addTagData = event => {\n if (event.target.value !== \'\') {\n setTagData([...tagData, event.target.value]);\n event.target.value = \'\';\n }\n };\n return (\n <div className="tag-input">\n <ul className="tags">\n {tagData.map((tag, index) => (\n <li key={index} className="tag">\n <span className="tag-title">{tag}</span>\n <span\n className="tag-close-icon"\n onClick={() => removeTagData(index)}\n >\n x\n </span>\n </li>\n ))}\n </ul>\n <input\n type="text"\n onKeyUp={event => (event.key === \'Enter\' ? addTagData(event) : null)}\n placeholder="Press enter to add a tag"\n />\n </div>\n );\n};', - exampleCodeBlockHtml: - 'ReactDOM.render(\n <TagInput tags={[\'Nodejs\', \'MongoDB\']} />,\n document.getElementById(\'root\')\n);', - styleCode: - '.tag-input {\n display: flex;\n flex-wrap: wrap;\n min-height: 48px;\n padding: 0 8px;\n border: 1px solid #d6d8da;\n border-radius: 6px;\n}\n\n.tag-input input {\n flex: 1;\n border: none;\n height: 46px;\n font-size: 14px;\n padding: 4px 0 0;\n}\n\n.tag-input input:focus {\n outline: transparent;\n}\n\n.tags {\n display: flex;\n flex-wrap: wrap;\n padding: 0;\n margin: 8px 0 0;\n}\n\n.tag {\n width: auto;\n height: 32px;\n display: flex;\n align-items: center;\n justify-content: center;\n color: #fff;\n padding: 0 8px;\n font-size: 14px;\n list-style: none;\n border-radius: 6px;\n margin: 0 8px 8px 0;\n background: #0052cc;\n}\n\n.tag-title {\n margin-top: 3px;\n}\n\n.tag-close-icon {\n display: block;\n width: 16px;\n height: 16px;\n line-height: 16px;\n text-align: center;\n font-size: 14px;\n margin-left: 8px;\n color: #0052cc;\n border-radius: 50%;\n background: #fff;\n cursor: pointer;\n}', - srcCode: - 'const TagInput = ({ tags }) => {\n const [tagData, setTagData] = React.useState(tags);\n const removeTagData = indexToRemove => {\n setTagData([...tagData.filter((_, index) => index !== indexToRemove)]);\n };\n const addTagData = event => {\n if (event.target.value !== \'\') {\n setTagData([...tagData, event.target.value]);\n event.target.value = \'\';\n }\n };\n return (\n
    \n
      \n {tagData.map((tag, index) => (\n
    • \n {tag}\n removeTagData(index)}\n >\n x\n \n
    • \n ))}\n
    \n (event.key === \'Enter\' ? addTagData(event) : null)}\n placeholder="Press enter to add a tag"\n />\n
    \n );\n};', - exampleCode: - "ReactDOM.render(\n ,\n document.getElementById('root')\n);", - author: 'oscc', - seoDescription: 'Renders a tag input field.', - repository: '30react', - }, -]; - -export const snippets = [ - ...blogSnippets, - ...codeSnippets, - ...cssSnippets, - ...reactSnippets, -]; - -export const authors = [ - { - name: 'Angelos Chalaris', - profile: 'https://github.com/chalarangelo', - id: 'chalarangelo', - }, - { - name: 'OSCC', - profile: '/faq', - id: 'oscc', - }, -]; - -export const languages = [ - { - id: 'html', - long: 'html', - short: 'html', - name: 'HTML', - }, - { - id: 'javascript', - long: 'javascript', - short: 'js', - name: 'JavaScript', - icon: 'js', - }, - { - id: 'css', - long: 'css', - short: 'css', - name: 'CSS', - icon: 'css', - }, - { - id: 'react', - long: 'react', - short: 'jsx', - name: 'React', - icon: 'react', - }, -]; - -export const tags = [ - { - id: '30blog_css', - slugPrefix: '/articles/t/css', - name: 'CSS Articles', - shortName: 'CSS Articles', - description: - 'The coding articles collection contains curated stories, tips, questions and answers on a wide variety of topics. The main focus of these articles revolves around the languages and technologies presented in snippets, as well as career advice and lessons.', - shortDescription: - 'Discover dozens of programming articles, covering a wide variety of topics and technologies.', - splash: 'camera.png', - icon: 'css', - repository: '30blog', - }, - { - id: '30blog_javascript', - slugPrefix: '/articles/t/javascript', - name: 'JavaScript Articles', - shortName: 'JavaScript Articles', - description: - 'The coding articles collection contains curated stories, tips, questions and answers on a wide variety of topics. The main focus of these articles revolves around the languages and technologies presented in snippets, as well as career advice and lessons.', - shortDescription: - 'Discover dozens of programming articles, covering a wide variety of topics and technologies.', - splash: 'laptop-plant.png', - icon: 'js', - repository: '30blog', - }, - { - id: '30blog_react', - slugPrefix: '/articles/t/react', - name: 'React Articles', - shortName: 'React Articles', - description: - 'The coding articles collection contains curated stories, tips, questions and answers on a wide variety of topics. The main focus of these articles revolves around the languages and technologies presented in snippets, as well as career advice and lessons.', - shortDescription: - 'Discover dozens of programming articles, covering a wide variety of topics and technologies.', - splash: 'plant-window.png', - icon: 'react', - repository: '30blog', - }, - { - id: '30code_date', - slugPrefix: '/js/t/date', - name: 'JavaScript Date Snippets', - shortName: 'JavaScript Date', - description: - 'The JavaScript snippet collection contains a wide variety of ES6 helper functions. It includes helpers for dealing with primitives, arrays and objects, as well as algorithms, DOM manipulation functions and Node.js utilities.', - shortDescription: - 'Browse a wide variety of ES6 helper functions, including array operations, DOM manipulation, algorithms and Node.js utilities.', - splash: 'succulent.png', - icon: 'js', - repository: '30code', - }, - { - id: '30code_node', - slugPrefix: '/js/t/node', - name: 'Node.js Snippets', - shortName: 'Node.js', - description: - 'The Node.js snippet collection contains JavaScript utilities for Node.js 14.x. It includes helper functions related to server-side code and filesystem operations, while general-purpose helpers can be found in the JavaScript snippet collection.', - shortDescription: - 'Discover a collection of server-side JavaScript utility functions for Node.js 14.x.', - splash: 'coffee-drip.png', - icon: 'node', - repository: '30code', - }, - { - id: '30code_string', - slugPrefix: '/js/t/string', - name: 'JavaScript String Snippets', - shortName: 'JavaScript String', - description: - 'The JavaScript snippet collection contains a wide variety of ES6 helper functions. It includes helpers for dealing with primitives, arrays and objects, as well as algorithms, DOM manipulation functions and Node.js utilities.', - shortDescription: - 'Browse a wide variety of ES6 helper functions, including array operations, DOM manipulation, algorithms and Node.js utilities.', - splash: 'laptop-plant.png', - icon: 'js', - repository: '30code', - }, - { - id: '30css_visual', - slugPrefix: '/css/t/visual', - name: 'CSS Visual Snippets', - shortName: 'CSS Visual', - description: - 'The CSS snippet collection contains utilities and interactive examples for CSS3. It includes modern techniques for creating commonly-used layouts, styling and animating elements, as well as snippets for handling user interactions.', - shortDescription: - 'A snippet collection of interactive CSS3 examples, covering layouts, styling, animation and user interactions.', - splash: 'camera.png', - icon: 'css', - repository: '30css', - }, - { - id: '30react_components', - slugPrefix: '/react/t/components', - name: 'React Components', - shortName: 'React Components', - description: - 'The React snippet collection contains function components and reusable hooks for React 16.', - shortDescription: - 'Discover a collection of reusable function components for React 16.', - splash: 'succulent.png', - icon: 'react', - repository: '30react', - }, - { - id: '30react_hooks', - slugPrefix: '/react/t/hooks', - name: 'React Hooks', - shortName: 'React Hooks', - description: - 'The React snippet collection contains function components and reusable hooks for React 16.', - shortDescription: 'Discover a collection of reusable hooks for React 16.', - splash: 'succulent-cluster.png', - icon: 'react', - repository: '30react', - }, -]; - -export const collectionListingConfig = { - dataName: 'Snippet Collections', - dataSplash: 'widescreen.png', - dataDescription: - '30 seconds of code provides a wide variety of snippet and article collections for all your development needs. Explore individual language collections or browse through collections about specific topics and programming concepts.', - dataFeaturedListings: [ - 'language/js', - 'language/css', - 'tag/react/t/hooks', - 'language/python', - 'tag/js/t/algorithm', - 'language/git', - 'tag/react/t/components', - 'blog/articles', - 'tag/js/t/node', - 'collection/c/js-data-structures', - 'collection/c/react-rendering', - 'collection/c/tips', - 'collection/c/css-centering', - 'collection/c/js-colors', - 'collection/c/js-array-tricks', - 'collection/c/js-comparison', - 'collection/c/react-testing', - 'collection/c/js-generators', - 'collection/c/cheatsheets', - ], -}; - -export const mainListingConfig = { - dataName: 'Snippets & Articles', - dataSplash: 'laptop-plant.png', - dataDescription: - '30 seconds of code provides a curated collection of short code snippets for all your development needs. Our collection spans many topics, ranging from simple coding problems to theoretical concepts and development techniques.', -}; - -export const content = { - repositories, - collections, - snippets, - authors, - languages, - tags, - collectionListingConfig, - mainListingConfig, -}; From 1b42c3d725677fcba2d0ebd0a1e19721228d1819 Mon Sep 17 00:00:00 2001 From: Angelos Chalaris Date: Sun, 7 May 2023 19:50:09 +0300 Subject: [PATCH 44/44] Bump content source --- content | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/content b/content index 2ecadbada..22e756d1f 160000 --- a/content +++ b/content @@ -1 +1 @@ -Subproject commit 2ecadbada9a18e2420d1c8123e81c1bc1c6c3839 +Subproject commit 22e756d1fc4c00c6245a764bb37d656178d8c485