UNPKG

kss

Version:

The Node.js port of KSS: A methodology for documenting CSS and building style guides

429 lines (380 loc) 15 kB
'use strict'; const KssSection = require('./kss_section'); /** * The `kss/lib/kss_styleguide` module is normally accessed via the * [`KssStyleGuide()`]{@link module:kss.KssStyleGuide} class of the `kss` * module: * ``` * const KssStyleGuide = require('kss').KssStyleGuide; * ``` * @private * @module kss/lib/kss_styleguide */ /** * A KssStyleGuide object represents multi-section style guide. * * This class is normally accessed via the [`kss`]{@link module:kss} module: * ``` * const KssStyleGuide = require('kss').KssStyleGuide; * ``` * * @alias module:kss.KssStyleGuide */ class KssStyleGuide { /** * Creates a KssStyleGuide object and stores the given data. * * If passed an object, it will add `autoInit`, `customPropertyNames`, and * `sections` properties. * * @param {Object} [data] An object of data. */ constructor(data) { data = data || {}; this.meta = { autoInit: false, files: data.files || [], hasNumericReferences: true, needsDepth: false, needsReferenceNumber: false, needsSort: false, referenceDelimiter: '.', referenceMap: {}, weightMap: {} }; this.data = { customPropertyNames: [], sections: [] }; if (data.customPropertyNames) { this.customPropertyNames(data.customPropertyNames); } if (data.sections) { // Note that auto-initialization is temporarily off since we don't want to // init this new object until after these sections are added. this.sections(data.sections); } // Now that all sections are added, turn on auto-initialization. But allow a // flag passed to the constructor to turn off auto-initialization. if (data.autoInit !== false) { this.meta.autoInit = true; } if (this.meta.autoInit) { this.init(); } } /** * Return the `KssStyleGuide` as a JSON object. * * @returns {Object} A JSON object representation of the KssStyleGuide. */ toJSON() { let returnObject; returnObject = { customPropertyNames: this.customPropertyNames(), hasNumericReferences: this.hasNumericReferences(), referenceDelimiter: this.referenceDelimiter() }; returnObject.sections = this.sections().map(section => { return section.toJSON(); }); return returnObject; } /** * Toggles the auto-initialization setting of this style guide. * * If a `false` value is provided, auto-initialization is disabled and users * will be required to call `init()` manually after adding sections via * `sections()`. If a `true' value is provided, auto-initialization will be * enabled and the `init()` method will immediately be called. * * @param {boolean} autoInit The new setting for auto-initialization. * @returns {KssStyleGuide} The `KssStyleGuide` object is returned to allow * chaining of methods. */ autoInit(autoInit) { this.meta.autoInit = !!autoInit; if (this.meta.autoInit) { this.init(); } // Allow chaining. return this; } /** * Sorts the style guides sections and (re-)initializes some section values. * * Some section data is dependent on the state of the KssStyleGuide. When * sections are added with `sections()`, it determines what updates are needed. * If needed, this method: * - Calculates the depth of the reference for each section. e.g. Section 2.1.7 * has a depth of 3. * - Sorts all the sections by reference. * - Calculates a reference number if the style guide uses * word-based references. * * By default this method is called automatically whenever new sections are * added to the style guide. This * * @returns {KssStyleGuide} Returns the `KssStyleGuide` object to allow chaining * of methods. */ init() { if (this.data.sections.length) { let numSections = this.data.sections.length; // The delimiter has changed, so recalculate the depth of each section's // reference. if (this.meta.needsDepth) { for (let i = 0; i < numSections; i++) { this.data.sections[i].depth(this.data.sections[i].reference().split(this.referenceDelimiter()).length); } this.meta.needsDepth = false; } // Sort all the sections. if (this.meta.needsSort) { let delimiter = this.referenceDelimiter(); // Sorting helper function that gets the weight of the given reference at // the given depth. e.g. `getWeight('4.3.2.2', 2)` will return the weight // for section 4.3. let getWeight = (reference, depth) => { reference = reference.toLowerCase().split(delimiter, depth).join(delimiter); return this.meta.weightMap[reference] ? this.meta.weightMap[reference] : 0; }; // Sort sections based on the references. this.data.sections.sort((a, b) => { // Split the 2 references into chunks by their period or dash separators. let refsA = a.reference().toLowerCase().split(delimiter), refsB = b.reference().toLowerCase().split(delimiter), weightA, weightB, maxRefLength = Math.max(refsA.length, refsB.length); // Compare each set of chunks until we know which reference should be listed first. for (let i = 0; i < maxRefLength; i++) { if (refsA[i] && refsB[i]) { // If the 2 chunks are unequal, compare them. if (refsA[i] !== refsB[i]) { // If the chunks have different weights, sort by weight. weightA = getWeight(a.reference(), i + 1); weightB = getWeight(b.reference(), i + 1); if (weightA !== weightB) { return weightA - weightB; } else if (refsA[i].match(/^\d+$/) && refsB[i].match(/^\d+$/)) { // If both chunks are digits, use numeric sorting. return refsA[i] - refsB[i]; } else { // Otherwise, use alphabetical string sorting. return (refsA[i] > refsB[i]) ? 1 : -1; } } } else { // If 1 of the chunks is empty, it goes first. return refsA[i] ? 1 : -1; } } return 0; }); this.meta.needsSort = false; } // Create an auto-incremented reference number if the references are not // number-based references. if (this.meta.needsReferenceNumber) { let autoIncrement = [0], ref, previousRef = []; for (let i = 0; i < numSections; i++) { ref = this.data.sections[i].reference(); // Compare the previous Ref to the new Ref. ref = ref.split(this.referenceDelimiter()); // If they are already equal, we don't need to increment the section number. if (previousRef.join(this.referenceDelimiter()) !== ref.join(this.referenceDelimiter())) { let incrementIndex = 0; for (let index = 0; index < previousRef.length; index += 1) { // Find the index where the refs differ. if (index >= ref.length || previousRef[index] !== ref[index]) { break; } incrementIndex = index + 1; } if (incrementIndex < autoIncrement.length) { // Increment the part where the refs started to differ. autoIncrement[incrementIndex]++; // Trim off the extra parts of the autoincrement where the refs differed. autoIncrement = autoIncrement.slice(0, incrementIndex + 1); } // Add parts to the autoincrement to ensure it is the same length as the new ref. for (let index = autoIncrement.length; index < ref.length; index += 1) { autoIncrement[index] = 1; } } this.data.sections[i].referenceNumber(autoIncrement.join('.')); previousRef = ref; } this.meta.needsReferenceNumber = false; } } // Allow chaining. return this; } /** * Gets or sets the list of custom properties of the style guide. * * If the `names` value is provided, the names are added to the style guide's * list of custom properties. Otherwise, the style guide's list of custom * properties is returned. * * @param {string|string[]} [names] Optional. The names of of the section. * @returns {KssStyleGuide|string[]} If `names` is given, the `KssStyleGuide` * object is returned to allow chaining of methods. Otherwise, the list of * custom properties of the style guide is returned. */ customPropertyNames(names) { if (typeof names === 'undefined') { return this.data.customPropertyNames; } if (!(names instanceof Array)) { names = [names]; } for (let i = 0; i < names.length; i++) { if (this.data.customPropertyNames.indexOf(names[i]) === -1) { this.data.customPropertyNames.push(names[i]); } } // Allow chaining. return this; } /** * Returns whether the style guide has numeric references or not. * * @returns {boolean} Whether the style guide has numeric references or not. */ hasNumericReferences() { return this.meta.hasNumericReferences; } /** * Returns the delimiter used in the style guide's section references. * * @returns {string} The delimiter used in the section references. */ referenceDelimiter() { return this.meta.referenceDelimiter; } /** * Gets or sets the sections of the style guide. * * If `sections` objects are provided, the sections are added to the style * guide. Otherwise, a search is performed to return the desired sections. * * There's a few ways to use search with this method: * - `sections()` returns all of the sections. * * Using strings: * - `sections('2')` returns Section 2. * - `sections('2.*')` returns Section 2 and all of its descendants. * - `sections('2.x')` returns Section 2's children only. * - `sections('2.x.x')` returns Section 2's children, and their children too. * * Or Regular Expressions: * - `sections(/2\.[1-5]/)` returns Sections 2.1 through to 2.5. * * @param {Object|Object[]|string|RegExp} [sections] Optional. A section object * or array of secction objects to add to the style guide. Or a string or * Regexp object to match a KssSection's style guide reference. * @returns {KssStyleGuide|KssSection|KssSection[]|boolean} If `sections` is * given, the `KssStyleGuide` object is returned to allow chaining of methods. * Otherwise, the exact KssSection requested, an array of KssSection objects * matching the query, or false is returned. */ sections(sections) { let query, matchedSections = []; if (typeof sections === 'undefined') { return this.data.sections; } // If we are given an object, assign the properties. if (typeof sections === 'object' && !(sections instanceof RegExp)) { if (!(sections instanceof Array)) { sections = [sections]; } sections.forEach(section => { let originalDelimiter = this.referenceDelimiter(); if (!(section instanceof KssSection)) { section = new KssSection(section); } // Set the style guide for each section. section.styleGuide(this); // Determine if the references are number-based or word-based. this.meta.hasNumericReferences = this.meta.hasNumericReferences && /^[\.\d]+$/.test(section.reference()); // Store the reference for quick searching later. this.meta.referenceMap[section.reference()] = section; // Store the section's weight. this.meta.weightMap[section.reference().toLowerCase()] = section.weight(); // Determine the separator used in references; e.g. 'a - b' or 'a.b'. if (section.reference().indexOf(' - ') > -1) { this.meta.referenceDelimiter = ' - '; } // Add the section to the style guide. this.data.sections.push(section); // If the delimiter changed, flag the depths as needing recalculation. if (originalDelimiter !== this.referenceDelimiter()) { this.meta.needsDepth = true; } else { // Set the depth of this section's reference. section.depth(section.reference().split(this.referenceDelimiter()).length); } // Determine if all sections need their reference number recalculated. if (!this.meta.hasNumericReferences) { this.meta.needsReferenceNumber = true; } // A new section means all sections need to be sorted. this.meta.needsSort = true; }); // Automatically re-initialize the style guide. if (this.meta.autoInit) { this.init(); } // Allow chaining. return this; } // Otherwise, we should search for the requested section. query = sections; // Exact queries. if (typeof query === 'string') { // If the query is '*', 'x', or ends with '.*', ' - *', '.x', or ' - x', // then it is not an exact query. if (!(/(^[x\*]$|\s\-\s[x\*]$|\.[x\*]$)/.test(query))) { if (this.meta.referenceMap[query]) { return this.meta.referenceMap[query]; } else { return false; } } } // Convert regex strings into proper JavaScript RegExp objects. if (!(query instanceof RegExp)) { let delim = this.referenceDelimiter() === '.' ? '\\.' : '\\ \\-\\ '; query = new RegExp( query // Convert '*' to a simple .+ regex. .replace(/^\*$/, '.+') // Convert 'x' to a regex matching one level of reference. .replace(/^x$/, '^.+?(?=($|' + delim + '))') // Convert '.*' or ' - *' to a ([delim].+){0,1} regex. .replace(/(\.|\s+\-\s+)\*$/g, '(' + delim + '.+){0,1}') // Convert the first '.x' or ' - x' to a regex matching one sub-level // of a reference. .replace(/(\.|\s+\-\s+)x\b/, delim + '.+?(?=($|' + delim + '))') // Convert any remaining '.x' or ' - x' to a regex matching zero or one // sub-levels of a reference. .replace(/(\.|\s+\-\s+)x\b/g, '(' + delim + '.+?(?=($|' + delim + '))){0,1}') // Convert any remaining '-' into '\-' .replace(/([^\\])\-/g, '$1\\-') ); } // General (regex) search for (let i = 0; i < this.data.sections.length; i += 1) { let match = this.data.sections[i].reference().match(query); // The regex must match the full reference. if (match && match[0] === this.data.sections[i].reference()) { matchedSections.push(this.data.sections[i]); } } return matchedSections; } } module.exports = KssStyleGuide;