@actualwave/codemirror-lang-embed-tailwind 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/completion.js ADDED
@@ -0,0 +1,59 @@
1
+ const { syntaxTree } = require('@codemirror/language');
2
+ const { getTaggedTemplateTagPath, templateContentRanges, docAsInput } = require('@actualwave/codemirror-lang-embed-core');
3
+ const { TAILWIND_CLASS_NAMES } = require('./data.js');
4
+
5
+ // Finds the innermost TemplateString node at `pos` (if any) whose tag is `tw`.
6
+ // Mirrors embed-core's getTaggedTemplateTagPath usage in parseMixed, but
7
+ // driving a CompletionSource instead of a nested parser — see TODO.md item 6:
8
+ // "same tag-name-matching primitive used for parseMixed, just driving
9
+ // completions instead of a sub-parser."
10
+ function findTwTemplateAt(state, pos) {
11
+ const tree = syntaxTree(state);
12
+ const node = tree.resolveInner(pos, -1);
13
+ for (let cur = node; cur; cur = cur.parent) {
14
+ if (cur.name !== 'TemplateString') continue;
15
+ const tagPath = getTaggedTemplateTagPath(cur, docAsInput(state.doc));
16
+ if (tagPath && tagPath.length === 1 && tagPath[0] === 'tw') return cur;
17
+ }
18
+ return null;
19
+ }
20
+
21
+ function isInsideContentRanges(pos, ranges) {
22
+ return ranges.some((r) => pos >= r.from && pos <= r.to);
23
+ }
24
+
25
+ /**
26
+ * A `CompletionSource` (see @codemirror/autocomplete) that suggests Tailwind
27
+ * (twrnc) class names while the cursor is inside a `` tw`...` `` tagged
28
+ * template's literal content, and declines (returns null) everywhere else so
29
+ * normal JS/TSX completions are unaffected.
30
+ */
31
+ function tailwindCompletionSource(context) {
32
+ const templateNode = findTwTemplateAt(context.state, context.pos);
33
+ if (!templateNode) return null;
34
+
35
+ const ranges = templateContentRanges(templateNode);
36
+ if (!isInsideContentRanges(context.pos, ranges)) return null;
37
+
38
+ const word = context.matchBefore(/[^\s`]*/);
39
+ if (!word) return null;
40
+ if (word.from === word.to && !context.explicit) return null;
41
+
42
+ const query = word.text;
43
+ const from = word.from;
44
+
45
+ // Arbitrary-value bracket syntax (bg-[#123456], p-[13px]) — don't try to
46
+ // complete inside the brackets, just don't offer suggestions there (stretch
47
+ // goal per TODO.md item 6; v1 just avoids breaking on it).
48
+ if (query.includes('[')) return null;
49
+
50
+ const options = TAILWIND_CLASS_NAMES.filter((name) => name.startsWith(query)).map((label) => ({
51
+ label,
52
+ type: 'class',
53
+ }));
54
+ if (!options.length) return null;
55
+
56
+ return { from, options, validFor: /^[^\s`]*$/ };
57
+ }
58
+
59
+ module.exports = { tailwindCompletionSource, findTwTemplateAt, isInsideContentRanges };
package/data.js ADDED
@@ -0,0 +1,135 @@
1
+ // Categorized Tailwind/twrnc utility class list, generated from small scale
2
+ // tables rather than hand-listed one by one — see TODO.md item 6: "Category
3
+ // must be data-driven per class, not prefix-derived. Tailwind overloads
4
+ // prefixes across categories (`text-lg` is typography, `text-red-500` is
5
+ // color, `text-center` is alignment/layout, all starting with `text-`)."
6
+ // Web-only categories (grid, filters, tables, SVG) are intentionally omitted
7
+ // — this targets the RN-relevant subset twrnc actually implements.
8
+
9
+ const SPACING_SCALE = [
10
+ '0', 'px', '0.5', '1', '1.5', '2', '2.5', '3', '3.5', '4', '5', '6', '7', '8',
11
+ '9', '10', '11', '12', '14', '16', '20', '24', '28', '32', '36', '40', '44',
12
+ '48', '52', '56', '60', '64', '72', '80', '96',
13
+ ];
14
+
15
+ const FRACTIONS = [
16
+ '1/2', '1/3', '2/3', '1/4', '2/4', '3/4', '1/5', '2/5', '3/5', '4/5',
17
+ '1/6', '2/6', '3/6', '4/6', '5/6',
18
+ '1/12', '2/12', '3/12', '4/12', '5/12', '6/12', '7/12', '8/12', '9/12', '10/12', '11/12',
19
+ 'full',
20
+ ];
21
+
22
+ const COLOR_NAMES = [
23
+ 'slate', 'gray', 'zinc', 'neutral', 'stone', 'red', 'orange', 'amber', 'yellow',
24
+ 'lime', 'green', 'emerald', 'teal', 'cyan', 'sky', 'blue', 'indigo', 'violet',
25
+ 'purple', 'fuchsia', 'pink', 'rose',
26
+ ];
27
+
28
+ const COLOR_SHADES = ['50', '100', '200', '300', '400', '500', '600', '700', '800', '900', '950'];
29
+
30
+ const OPACITY_SCALE = ['0', '5', '10', '20', '25', '30', '40', '50', '60', '70', '75', '80', '90', '95', '100'];
31
+
32
+ const ROUNDED_SIZES = ['none', 'sm', '', 'md', 'lg', 'xl', '2xl', '3xl', 'full'];
33
+ const ROUNDED_CORNERS = ['t', 'r', 'b', 'l', 'tl', 'tr', 'br', 'bl'];
34
+
35
+ // class -> category, built up by the add() calls below.
36
+ const CLASSES = new Map();
37
+
38
+ function add(category, ...names) {
39
+ for (const name of names) {
40
+ if (name) CLASSES.set(name, category);
41
+ }
42
+ }
43
+
44
+ function addScaled(category, prefixes, scales) {
45
+ for (const prefix of prefixes) {
46
+ for (const scale of scales) {
47
+ add(category, `${prefix}-${scale}`);
48
+ }
49
+ }
50
+ }
51
+
52
+ // --- Spacing: padding, margin (+ negative), gap, space-between ---
53
+ addScaled('spacing', ['p', 'pt', 'pr', 'pb', 'pl', 'px', 'py'], SPACING_SCALE);
54
+ addScaled('spacing', ['m', 'mt', 'mr', 'mb', 'ml', 'mx', 'my'], SPACING_SCALE);
55
+ for (const prefix of ['m', 'mt', 'mr', 'mb', 'ml', 'mx', 'my']) {
56
+ for (const scale of SPACING_SCALE) add('spacing', `-${prefix}-${scale}`);
57
+ }
58
+ addScaled('spacing', ['gap', 'gap-x', 'gap-y'], SPACING_SCALE);
59
+ addScaled('spacing', ['space-x', 'space-y'], SPACING_SCALE);
60
+ add('spacing', 'space-x-reverse', 'space-y-reverse');
61
+
62
+ // --- Sizing: width/height (+ min/max), fractions, full/screen/auto ---
63
+ const SIZING_PREFIXES = ['w', 'h', 'min-w', 'min-h', 'max-w', 'max-h'];
64
+ addScaled('sizing', SIZING_PREFIXES, SPACING_SCALE);
65
+ addScaled('sizing', SIZING_PREFIXES, FRACTIONS);
66
+ for (const prefix of SIZING_PREFIXES) {
67
+ add('sizing', `${prefix}-auto`, `${prefix}-screen`, `${prefix}-min`, `${prefix}-max`, `${prefix}-fit`);
68
+ }
69
+
70
+ // --- Layout: display, flexbox container/item, alignment, order ---
71
+ add('layout', 'flex', 'hidden', 'inline', 'inline-block', 'block');
72
+ add('layout', 'flex-row', 'flex-row-reverse', 'flex-col', 'flex-col-reverse');
73
+ add('layout', 'flex-wrap', 'flex-wrap-reverse', 'flex-nowrap');
74
+ add('layout', 'flex-1', 'flex-auto', 'flex-initial', 'flex-none');
75
+ add('layout', 'grow', 'grow-0', 'shrink', 'shrink-0');
76
+ addScaled('layout', ['basis'], SPACING_SCALE);
77
+ addScaled('layout', ['basis'], FRACTIONS);
78
+ add('layout', 'basis-auto');
79
+ add('layout', 'items-start', 'items-end', 'items-center', 'items-baseline', 'items-stretch');
80
+ add('layout', 'content-start', 'content-end', 'content-center', 'content-between', 'content-around', 'content-evenly');
81
+ add('layout', 'self-auto', 'self-start', 'self-end', 'self-center', 'self-stretch', 'self-baseline');
82
+ add('layout', 'justify-start', 'justify-end', 'justify-center', 'justify-between', 'justify-around', 'justify-evenly');
83
+ for (let i = 1; i <= 12; i++) add('layout', `order-${i}`);
84
+ add('layout', 'order-first', 'order-last', 'order-none');
85
+
86
+ // --- Positioning: position, inset/top/right/bottom/left, z-index ---
87
+ add('positioning', 'absolute', 'relative', 'static');
88
+ addScaled('positioning', ['inset', 'inset-x', 'inset-y', 'top', 'right', 'bottom', 'left'], SPACING_SCALE);
89
+ add('positioning', 'z-0', 'z-10', 'z-20', 'z-30', 'z-40', 'z-50', 'z-auto');
90
+
91
+ // --- Typography: size/weight/style/align/decoration/transform/spacing ---
92
+ add('typography', 'text-xs', 'text-sm', 'text-base', 'text-lg', 'text-xl', 'text-2xl', 'text-3xl', 'text-4xl', 'text-5xl', 'text-6xl');
93
+ add('typography', 'font-thin', 'font-extralight', 'font-light', 'font-normal', 'font-medium', 'font-semibold', 'font-bold', 'font-extrabold', 'font-black');
94
+ add('typography', 'italic', 'not-italic');
95
+ add('typography', 'text-left', 'text-center', 'text-right', 'text-justify');
96
+ add('typography', 'underline', 'line-through', 'no-underline');
97
+ add('typography', 'uppercase', 'lowercase', 'capitalize', 'normal-case');
98
+ add('typography', 'tracking-tighter', 'tracking-tight', 'tracking-normal', 'tracking-wide', 'tracking-wider', 'tracking-widest');
99
+ add('typography', 'leading-none', 'leading-tight', 'leading-snug', 'leading-normal', 'leading-relaxed', 'leading-loose');
100
+ addScaled('typography', ['leading'], ['3', '4', '5', '6', '7', '8', '9', '10']);
101
+
102
+ // --- Colors: background/text/border color scales (distinct strings from the
103
+ // typography/border families above — e.g. `text-red-500` vs `text-lg`) ---
104
+ for (const prefix of ['bg', 'text', 'border']) {
105
+ for (const color of COLOR_NAMES) {
106
+ for (const shade of COLOR_SHADES) add('colors', `${prefix}-${color}-${shade}`);
107
+ }
108
+ add('colors', `${prefix}-black`, `${prefix}-white`, `${prefix}-transparent`, `${prefix}-current`);
109
+ }
110
+
111
+ // --- Borders: width/side/style/radius (border *color* lives in "colors") ---
112
+ add('borders', 'border', 'border-0', 'border-2', 'border-4', 'border-8');
113
+ for (const side of ['t', 'r', 'b', 'l']) {
114
+ add('borders', `border-${side}`, `border-${side}-0`, `border-${side}-2`, `border-${side}-4`, `border-${side}-8`);
115
+ }
116
+ add('borders', 'border-solid', 'border-dashed', 'border-dotted');
117
+ for (const size of ROUNDED_SIZES) add('borders', size ? `rounded-${size}` : 'rounded');
118
+ for (const corner of ROUNDED_CORNERS) {
119
+ for (const size of ROUNDED_SIZES) add('borders', size ? `rounded-${corner}-${size}` : `rounded-${corner}`);
120
+ }
121
+
122
+ // --- Effects: opacity, shadow ---
123
+ for (const value of OPACITY_SCALE) add('effects', `opacity-${value}`);
124
+ add('effects', 'shadow', 'shadow-sm', 'shadow-md', 'shadow-lg', 'shadow-xl', 'shadow-2xl', 'shadow-none');
125
+
126
+ /** @type {ReadonlyMap<string, string>} className -> category */
127
+ const TAILWIND_CLASSES = CLASSES;
128
+
129
+ /** Sorted array of all known class names, for completion filtering. */
130
+ const TAILWIND_CLASS_NAMES = Array.from(CLASSES.keys()).sort();
131
+
132
+ /** All category names, in the order documented by TODO.md item 6. */
133
+ const TAILWIND_CATEGORIES = ['layout', 'spacing', 'sizing', 'typography', 'colors', 'borders', 'effects', 'positioning'];
134
+
135
+ module.exports = { TAILWIND_CLASSES, TAILWIND_CLASS_NAMES, TAILWIND_CATEGORIES };
package/decoration.js ADDED
@@ -0,0 +1,106 @@
1
+ const { syntaxTree } = require('@codemirror/language');
2
+ const { Decoration, ViewPlugin, EditorView } = require('@codemirror/view');
3
+ const { RangeSetBuilder, Prec } = require('@codemirror/state');
4
+ const { getTaggedTemplateTagPath, templateContentRanges, docAsInput } = require('@actualwave/codemirror-lang-embed-core');
5
+ const { TAILWIND_CLASSES, TAILWIND_CATEGORIES } = require('./data.js');
6
+
7
+ const TOKEN_RE = /[^\s`]+/g;
8
+
9
+ const decorationByCategory = new Map(
10
+ TAILWIND_CATEGORIES.map((category) => [category, Decoration.mark({ class: `cm-tw-${category}` })]),
11
+ );
12
+
13
+ // Collects every `tw`-tagged TemplateString's content ranges that intersect
14
+ // [from, to) — same tag-matching primitive embed-core uses for parseMixed and
15
+ // completion.js uses for the CompletionSource, just driving decoration this
16
+ // time (see TODO.md item 6).
17
+ function twContentRangesIn(state, from, to) {
18
+ const ranges = [];
19
+ syntaxTree(state).iterate({
20
+ from,
21
+ to,
22
+ enter(node) {
23
+ if (node.name !== 'TemplateString') return;
24
+ const tagPath = getTaggedTemplateTagPath(node.node, docAsInput(state.doc));
25
+ if (!tagPath || tagPath.length !== 1 || tagPath[0] !== 'tw') return;
26
+ for (const range of templateContentRanges(node.node)) ranges.push(range);
27
+ },
28
+ });
29
+ return ranges;
30
+ }
31
+
32
+ function buildDecorations(view) {
33
+ const builder = new RangeSetBuilder();
34
+ const seen = [];
35
+ for (const { from, to } of view.visibleRanges) {
36
+ for (const range of twContentRangesIn(view.state, from, to)) {
37
+ seen.push(range);
38
+ }
39
+ }
40
+ // Visible ranges can overlap at chunk boundaries — dedupe by range identity
41
+ // (same node can't produce the same [from,to) twice from disjoint queries,
42
+ // but a template spanning multiple visible chunks could; sort+merge is
43
+ // unnecessary for correctness here since Decoration ranges just need
44
+ // ascending, non-overlapping input, and token matches within one template
45
+ // range never repeat).
46
+ seen.sort((a, b) => a.from - b.from || a.to - b.to);
47
+
48
+ const text = view.state.doc;
49
+ for (const { from, to } of seen) {
50
+ const chunk = text.sliceString(from, to);
51
+ TOKEN_RE.lastIndex = 0;
52
+ let match;
53
+ while ((match = TOKEN_RE.exec(chunk))) {
54
+ const token = match[0];
55
+ const category = TAILWIND_CLASSES.get(token);
56
+ if (!category) continue; // unknown/arbitrary-value token — leave undecorated (v1)
57
+ const start = from + match.index;
58
+ builder.add(start, start + token.length, decorationByCategory.get(category));
59
+ }
60
+ }
61
+ return builder.finish();
62
+ }
63
+
64
+ // Mark decorations nest by facet precedence — the higher-precedence one
65
+ // becomes the *inner* DOM element (see @codemirror/view's Decoration.mark
66
+ // doc comment). @codemirror/language's syntaxHighlighting() wraps its
67
+ // treeHighlighter in Prec.high(...), so it paints `.tok-string` as the
68
+ // innermost span around the whole `tw`-tagged template content. Without
69
+ // out-precedencing that, our per-category `.cm-tw-*` span ends up as the
70
+ // *outer* element and `.tok-string`'s own color always wins (an element's
71
+ // own matching rule beats an ancestor's color regardless of specificity).
72
+ // Prec.highest here makes ours the inner span instead, so it wins.
73
+ const tailwindHighlightPlugin = Prec.highest(
74
+ ViewPlugin.fromClass(
75
+ class {
76
+ constructor(view) {
77
+ this.decorations = buildDecorations(view);
78
+ }
79
+
80
+ update(update) {
81
+ if (update.docChanged || update.viewportChanged) {
82
+ this.decorations = buildDecorations(update.view);
83
+ }
84
+ }
85
+ },
86
+ { decorations: (v) => v.decorations },
87
+ ),
88
+ );
89
+
90
+ // Default category colors. Downstream (react-native-codeditor's editor.html)
91
+ // overrides these per active editor theme, the same way _themePackageMap
92
+ // remaps @uiw theme exports — see TODO.md item 6's "Category colors need to
93
+ // be defined per editor theme... not hardcoded" note. These are just the
94
+ // standalone/demo defaults so the plugin isn't inert without that wiring.
95
+ const tailwindHighlightTheme = EditorView.baseTheme({
96
+ '.cm-tw-layout': { color: '#569cd6' },
97
+ '.cm-tw-spacing': { color: '#9cdcfe' },
98
+ '.cm-tw-sizing': { color: '#4ec9b0' },
99
+ '.cm-tw-typography': { color: '#c586c0' },
100
+ '.cm-tw-colors': { color: '#ce9178' },
101
+ '.cm-tw-borders': { color: '#d7ba7d' },
102
+ '.cm-tw-effects': { color: '#b5cea8' },
103
+ '.cm-tw-positioning': { color: '#dcdcaa' },
104
+ });
105
+
106
+ module.exports = { tailwindHighlightPlugin, tailwindHighlightTheme };
package/index.js ADDED
@@ -0,0 +1,21 @@
1
+ const { tailwindCompletionSource } = require('./completion.js');
2
+ const { tailwindHighlightPlugin, tailwindHighlightTheme } = require('./decoration.js');
3
+
4
+ // Unlike the other embed-* packages (embed-sql/embed-graphql/embed-css/
5
+ // embed-sksl), Tailwind has no grammar to parse — `` tw`...` `` content is a
6
+ // flat space-separated list of utility class names (see TODO.md item 6: "no
7
+ // language structure inside `tw`...` ` to parse... this isn't a parseMixed
8
+ // grammar problem like the other DSLs"). So instead of `createEmbedding()`
9
+ // (matcher + nested Language for the tag registry), this package exports
10
+ // `createSupportExtension(jsLanguageSupport)`, returning plain CM extensions
11
+ // (a completion source + a decoration ViewPlugin) to merge into the JS/TSX
12
+ // language's `support` array.
13
+ function createSupportExtension(jsLanguageSupport) {
14
+ return [
15
+ jsLanguageSupport.language.data.of({ autocomplete: tailwindCompletionSource }),
16
+ tailwindHighlightPlugin,
17
+ tailwindHighlightTheme,
18
+ ];
19
+ }
20
+
21
+ module.exports = { createSupportExtension };
package/package.json CHANGED
@@ -1,6 +1,25 @@
1
1
  {
2
2
  "name": "@actualwave/codemirror-lang-embed-tailwind",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Tailwind (twrnc) class-name completion and category-colored highlighting inside `tw` tagged templates in JS/TSX",
5
+ "main": "index.js",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://github.com/burdiuz/codemorror-lang-modules.git",
10
+ "directory": "packages/embed-tailwind"
11
+ },
12
+ "bugs": {
13
+ "url": "https://github.com/burdiuz/codemorror-lang-modules/issues"
14
+ },
15
+ "homepage": "https://github.com/burdiuz/codemorror-lang-modules/tree/main/packages/embed-tailwind",
16
+ "dependencies": {
17
+ "@actualwave/codemirror-lang-embed-core": "*"
18
+ },
19
+ "peerDependencies": {
20
+ "@codemirror/autocomplete": "^6.0.0",
21
+ "@codemirror/language": "^6.0.0",
22
+ "@codemirror/state": "^6.0.0",
23
+ "@codemirror/view": "^6.0.0"
24
+ }
25
+ }
package/README.md DELETED
@@ -1,3 +0,0 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.