summaryrefslogtreecommitdiff
path: root/scss/extension/compass/helpers.py
blob: e7251b37dd9bb7cfed2d69bb31d246f5cf23b212 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
"""Miscellaneous helper functions ported from Compass.

See: http://compass-style.org/reference/compass/helpers/

This collection is not necessarily complete or up-to-date.
"""
from __future__ import absolute_import
from __future__ import unicode_literals

import logging
import math
import os.path

import six

from . import CompassExtension
from scss import config
from scss.types import Boolean, Function, List, Null, Number, String, Url
from scss.util import to_str, getmtime, make_data_url
import re

log = logging.getLogger(__name__)
ns = CompassExtension.namespace

FONT_TYPES = {
    'woff': 'woff',
    'otf': 'opentype',
    'opentype': 'opentype',
    'ttf': 'truetype',
    'truetype': 'truetype',
    'svg': 'svg',
    'eot': 'embedded-opentype'
}


def add_cache_buster(url, mtime):
    fragment = url.split('#')
    query = fragment[0].split('?')
    if len(query) > 1 and query[1] != '':
        cb = '&_=%s' % (mtime)
        url = '?'.join(query) + cb
    else:
        cb = '?_=%s' % (mtime)
        url = query[0] + cb
    if len(fragment) > 1:
        url += '#' + fragment[1]
    return url


# ------------------------------------------------------------------------------
# Data manipulation

@ns.declare
def blank(*objs):
    """Returns true when the object is false, an empty string, or an empty list"""
    for o in objs:
        if isinstance(o, Boolean):
            is_blank = not o
        elif isinstance(o, String):
            is_blank = not len(o.value.strip())
        elif isinstance(o, List):
            is_blank = all(blank(el) for el in o)
        else:
            is_blank = False

        if not is_blank:
            return Boolean(False)

    return Boolean(True)


@ns.declare
def compact(*args):
    """Returns a new list after removing any non-true values"""
    use_comma = True
    if len(args) == 1 and isinstance(args[0], List):
        use_comma = args[0].use_comma
        args = args[0]

    return List(
        [arg for arg in args if arg],
        use_comma=use_comma,
    )


@ns.declare
def reject(lst, *values):
    """Removes the given values from the list"""
    lst = List.from_maybe(lst)
    values = frozenset(List.from_maybe_starargs(values))

    ret = []
    for item in lst:
        if item not in values:
            ret.append(item)
    return List(ret, use_comma=lst.use_comma)


@ns.declare
def first_value_of(*args):
    if len(args) == 1 and isinstance(args[0], String):
        first = args[0].value.split()[0]
        return type(args[0])(first)

    args = List.from_maybe_starargs(args)
    if len(args):
        return args[0]
    else:
        return Null()


@ns.declare_alias('-compass-list')
def dash_compass_list(*args):
    return List.from_maybe_starargs(args)


@ns.declare_alias('-compass-space-list')
def dash_compass_space_list(*lst):
    """
    If the argument is a list, it will return a new list that is space delimited
    Otherwise it returns a new, single element, space-delimited list.
    """
    ret = dash_compass_list(*lst)
    ret.value.pop('_', None)
    return ret


@ns.declare_alias('-compass-slice')
def dash_compass_slice(lst, start_index, end_index=None):
    start_index = Number(start_index).value
    end_index = Number(end_index).value if end_index is not None else None
    ret = {}
    lst = List(lst)
    if end_index:
        # This function has an inclusive end, but Python slicing is exclusive
        end_index += 1
    ret = lst.value[start_index:end_index]
    return List(ret, use_comma=lst.use_comma)


# ------------------------------------------------------------------------------
# Property prefixing

@ns.declare
def prefixed(prefix, *args):
    to_fnct_str = 'to_' + to_str(prefix).replace('-', '_')
    for arg in List.from_maybe_starargs(args):
        if hasattr(arg, to_fnct_str):
            return Boolean(True)
    return Boolean(False)


@ns.declare
def prefix(prefix, *args):
    to_fnct_str = 'to_' + to_str(prefix).replace('-', '_')
    args = list(args)
    for i, arg in enumerate(args):
        if isinstance(arg, List):
            _value = []
            for iarg in arg:
                to_fnct = getattr(iarg, to_fnct_str, None)
                if to_fnct:
                    _value.append(to_fnct())
                else:
                    _value.append(iarg)
            args[i] = List(_value)
        else:
            to_fnct = getattr(arg, to_fnct_str, None)
            if to_fnct:
                args[i] = to_fnct()

    return List.maybe_new(args, use_comma=True)


@ns.declare_alias('-moz')
def dash_moz(*args):
    return prefix('_moz', *args)


@ns.declare_alias('-svg')
def dash_svg(*args):
    return prefix('_svg', *args)


@ns.declare_alias('-css2')
def dash_css2(*args):
    return prefix('_css2', *args)


@ns.declare_alias('-pie')
def dash_pie(*args):
    return prefix('_pie', *args)


@ns.declare_alias('-webkit')
def dash_webkit(*args):
    return prefix('_webkit', *args)


@ns.declare_alias('-owg')
def dash_owg(*args):
    return prefix('_owg', *args)


@ns.declare_alias('-khtml')
def dash_khtml(*args):
    return prefix('_khtml', *args)


@ns.declare_alias('-ms')
def dash_ms(*args):
    return prefix('_ms', *args)


@ns.declare_alias('-o')
def dash_o(*args):
    return prefix('_o', *args)


# ------------------------------------------------------------------------------
# Selector generation

@ns.declare
def append_selector(selector, to_append):
    if isinstance(selector, List):
        lst = selector.value
    else:
        lst = String.unquoted(selector).value.split(',')
    to_append = String.unquoted(to_append).value.strip()
    ret = sorted(set(s.strip() + to_append for s in lst if s.strip()))
    ret = dict(enumerate(ret))
    ret['_'] = ','
    return ret


_elements_of_type_block = 'address, article, aside, blockquote, center, dd, details, dir, div, dl, dt, fieldset, figcaption, figure, footer, form, frameset, h1, h2, h3, h4, h5, h6, header, hgroup, hr, isindex, menu, nav, noframes, noscript, ol, p, pre, section, summary, ul'
_elements_of_type_inline = 'a, abbr, acronym, audio, b, basefont, bdo, big, br, canvas, cite, code, command, datalist, dfn, em, embed, font, i, img, input, kbd, keygen, label, mark, meter, output, progress, q, rp, rt, ruby, s, samp, select, small, span, strike, strong, sub, sup, textarea, time, tt, u, var, video, wbr'
_elements_of_type_table = 'table'
_elements_of_type_list_item = 'li'
_elements_of_type_table_row_group = 'tbody'
_elements_of_type_table_header_group = 'thead'
_elements_of_type_table_footer_group = 'tfoot'
_elements_of_type_table_row = 'tr'
_elements_of_type_table_cel = 'td, th'
_elements_of_type_html5_block = 'article, aside, details, figcaption, figure, footer, header, hgroup, menu, nav, section, summary'
_elements_of_type_html5_inline = 'audio, canvas, command, datalist, embed, keygen, mark, meter, output, progress, rp, rt, ruby, time, video, wbr'
_elements_of_type_html5 = 'article, aside, audio, canvas, command, datalist, details, embed, figcaption, figure, footer, header, hgroup, keygen, mark, menu, meter, nav, output, progress, rp, rt, ruby, section, summary, time, video, wbr'
_elements_of_type = {
    'block': sorted(_elements_of_type_block.replace(' ', '').split(',')),
    'inline': sorted(_elements_of_type_inline.replace(' ', '').split(',')),
    'table': sorted(_elements_of_type_table.replace(' ', '').split(',')),
    'list-item': sorted(_elements_of_type_list_item.replace(' ', '').split(',')),
    'table-row-group': sorted(_elements_of_type_table_row_group.replace(' ', '').split(',')),
    'table-header-group': sorted(_elements_of_type_table_header_group.replace(' ', '').split(',')),
    'table-footer-group': sorted(_elements_of_type_table_footer_group.replace(' ', '').split(',')),
    'table-row': sorted(_elements_of_type_table_footer_group.replace(' ', '').split(',')),
    'table-cell': sorted(_elements_of_type_table_footer_group.replace(' ', '').split(',')),
    'html5-block': sorted(_elements_of_type_html5_block.replace(' ', '').split(',')),
    'html5-inline': sorted(_elements_of_type_html5_inline.replace(' ', '').split(',')),
    'html5': sorted(_elements_of_type_html5.replace(' ', '').split(',')),
}


@ns.declare
def elements_of_type(display):
    d = String.unquoted(display)
    ret = _elements_of_type.get(d.value, None)
    if ret is None:
        raise Exception("Elements of type '%s' not found!" % d.value)
    return List(map(String, ret), use_comma=True)


@ns.declare
def enumerate_(prefix, frm, through, separator='-'):
    separator = String.unquoted(separator).value
    try:
        frm = int(getattr(frm, 'value', frm))
    except ValueError:
        frm = 1
    try:
        through = int(getattr(through, 'value', through))
    except ValueError:
        through = frm
    if frm > through:
        # DEVIATION: allow reversed enumerations (and ranges as range() uses enumerate, like '@for .. from .. through')
        frm, through = through, frm
        rev = reversed
    else:
        rev = lambda x: x

    ret = []
    for i in rev(range(frm, through + 1)):
        if prefix and prefix.value:
            ret.append(String.unquoted(prefix.value + separator + six.text_type(i)))
        else:
            ret.append(Number(i))

    return List(ret, use_comma=True)


@ns.declare_alias('headings')
@ns.declare
def headers(frm=None, to=None):
    if frm and to is None:
        if isinstance(frm, String) and frm.value.lower() == 'all':
            frm = 1
            to = 6
        else:
            try:
                to = int(getattr(frm, 'value', frm))
            except ValueError:
                to = 6
            frm = 1
    else:
        try:
            frm = 1 if frm is None else int(getattr(frm, 'value', frm))
        except ValueError:
            frm = 1
        try:
            to = 6 if to is None else int(getattr(to, 'value', to))
        except ValueError:
            to = 6
    ret = [String.unquoted('h' + six.text_type(i)) for i in range(frm, to + 1)]
    return List(ret, use_comma=True)


@ns.declare
def nest(*arguments):
    if isinstance(arguments[0], List):
        lst = arguments[0]
    elif isinstance(arguments[0], String):
        lst = arguments[0].value.split(',')
    else:
        raise TypeError("Expected list or string, got %r" % (arguments[0],))

    ret = []
    for s in lst:
        if isinstance(s, String):
            s = s.value
        elif isinstance(s, six.string_types):
            s = s
        else:
            raise TypeError("Expected string, got %r" % (s,))

        s = s.strip()
        if not s:
            continue

        ret.append(s)

    for arg in arguments[1:]:
        if isinstance(arg, List):
            lst = arg
        elif isinstance(arg, String):
            lst = arg.value.split(',')
        else:
            raise TypeError("Expected list or string, got %r" % (arg,))

        new_ret = []
        for s in lst:
            if isinstance(s, String):
                s = s.value
            elif isinstance(s, six.string_types):
                s = s
            else:
                raise TypeError("Expected string, got %r" % (s,))

            s = s.strip()
            if not s:
                continue

            for r in ret:
                if '&' in s:
                    new_ret.append(s.replace('&', r))
                else:
                    if not r or r[-1] in ('.', ':', '#'):
                        new_ret.append(r + s)
                    else:
                        new_ret.append(r + ' ' + s)
        ret = new_ret

    ret = [String.unquoted(s) for s in sorted(set(ret))]
    return List(ret, use_comma=True)


# This isn't actually from Compass, but it's just a shortcut for enumerate().
# DEVIATION: allow reversed ranges (range() uses enumerate() which allows reversed values, like '@for .. from .. through')
@ns.declare
def range_(frm, through=None):
    if through is None:
        through = frm
        frm = 1
    return enumerate_(None, frm, through)

# ------------------------------------------------------------------------------
# Working with CSS constants

OPPOSITE_POSITIONS = {
    'top': String.unquoted('bottom'),
    'bottom': String.unquoted('top'),
    'left': String.unquoted('right'),
    'right': String.unquoted('left'),
    'center': String.unquoted('center'),
}
DEFAULT_POSITION = [String.unquoted('center'), String.unquoted('top')]


def _position(opposite, positions):
    if positions is None:
        positions = DEFAULT_POSITION
    else:
        positions = List.from_maybe(positions)

    ret = []
    for pos in positions:
        if isinstance(pos, (String, six.string_types)):
            pos_value = getattr(pos, 'value', pos)
            if pos_value in OPPOSITE_POSITIONS:
                if opposite:
                    ret.append(OPPOSITE_POSITIONS[pos_value])
                else:
                    ret.append(pos)
                continue
            elif pos_value == 'to':
                # Gradient syntax keyword; leave alone
                ret.append(pos)
                continue

        elif isinstance(pos, Number):
            if pos.is_simple_unit('%'):
                if opposite:
                    ret.append(Number(100 - pos.value, '%'))
                else:
                    ret.append(pos)
                continue
            elif pos.is_simple_unit('deg'):
                # TODO support other angle types?
                if opposite:
                    ret.append(Number((pos.value + 180) % 360, 'deg'))
                else:
                    ret.append(pos)
                continue

        if opposite:
            log.warn("Can't find opposite for position %r" % (pos,))
        ret.append(pos)

    return List(ret, use_comma=False).maybe()


@ns.declare
def position(p):
    return _position(False, p)


@ns.declare
def opposite_position(p):
    return _position(True, p)


# ------------------------------------------------------------------------------
# Math

@ns.declare
def pi():
    return Number(math.pi)


@ns.declare
def e():
    return Number(math.e)


@ns.declare
def log_(number, base=None):
    if not isinstance(number, Number):
        raise TypeError("Expected number, got %r" % (number,))
    elif not number.is_unitless:
        raise ValueError("Expected unitless number, got %r" % (number,))

    if base is None:
        pass
    elif not isinstance(base, Number):
        raise TypeError("Expected number, got %r" % (base,))
    elif not base.is_unitless:
        raise ValueError("Expected unitless number, got %r" % (base,))

    if base is None:
        ret = math.log(number.value)
    else:
        ret = math.log(number.value, base.value)

    return Number(ret)


@ns.declare
def pow(number, exponent):
    return number ** exponent


ns.set_function('sqrt', 1, Number.wrap_python_function(math.sqrt))
ns.set_function('sin', 1, Number.wrap_python_function(math.sin))
ns.set_function('cos', 1, Number.wrap_python_function(math.cos))
ns.set_function('tan', 1, Number.wrap_python_function(math.tan))


# ------------------------------------------------------------------------------
# Fonts

def _fonts_root():
    return config.STATIC_ROOT if config.FONTS_ROOT is None else config.FONTS_ROOT


def _font_url(path, only_path=False, cache_buster=True, inline=False):
    filepath = String.unquoted(path).value
    file = None
    FONTS_ROOT = _fonts_root()
    if callable(FONTS_ROOT):
        try:
            _file, _storage = list(FONTS_ROOT(filepath))[0]
        except IndexError:
            filetime = None
        else:
            filetime = getmtime(_file, _storage)
        if filetime is None:
            filetime = 'NA'
        elif inline:
            file = _storage.open(_file)
    else:
        _path = os.path.join(FONTS_ROOT, filepath.strip('/'))
        filetime = getmtime(_path)
        if filetime is None:
            filetime = 'NA'
        elif inline:
            file = open(_path, 'rb')

    BASE_URL = config.FONTS_URL or config.STATIC_URL
    if file and inline:
        font_type = None
        if re.match(r'^([^?]+)[.](.*)([?].*)?$', path.value):
            font_type = String.unquoted(re.match(r'^([^?]+)[.](.*)([?].*)?$', path.value).groups()[1]).value

        try:
            mime = FONT_TYPES[font_type]
        except KeyError:
            raise Exception('Could not determine font type for "%s"' % path.value)

        mime = FONT_TYPES.get(font_type)
        if font_type == 'woff':
            mime = 'application/font-woff'
        elif font_type == 'eot':
            mime = 'application/vnd.ms-fontobject'
        url = make_data_url(
            (mime if '/' in mime else 'font/%s' % mime),
            file.read())
        file.close()
    else:
        url = '%s/%s' % (BASE_URL.rstrip('/'), filepath.lstrip('/'))
        if cache_buster and filetime != 'NA':
            url = add_cache_buster(url, filetime)

    if only_path:
        return String.unquoted(url)
    else:
        return Url.unquoted(url)


def _font_files(args, inline):
    if args == ():
        return String.unquoted("")

    fonts = []
    args_len = len(args)
    skip_next = False
    for index, arg in enumerate(args):
        if not skip_next:
            font_type = args[index + 1] if args_len > (index + 1) else None
            if font_type and font_type.value in FONT_TYPES:
                skip_next = True
            else:
                if re.match(r'^([^?]+)[.](.*)([?].*)?$', arg.value):
                    font_type = String.unquoted(re.match(r'^([^?]+)[.](.*)([?].*)?$', arg.value).groups()[1])

            if font_type.value in FONT_TYPES:
                fonts.append(List([
                    _font_url(arg, inline=inline),
                    Function(FONT_TYPES[font_type.value], 'format'),
                ], use_comma=False))
            else:
                raise Exception('Could not determine font type for "%s"' % arg.value)
        else:
            skip_next = False

    return List(fonts, separator=',')


@ns.declare
def font_url(path, only_path=False, cache_buster=True):
    """
    Generates a path to an asset found relative to the project's font directory.
    Passing a true value as the second argument will cause the only the path to
    be returned instead of a `url()` function
    """
    return _font_url(path, only_path, cache_buster, False)


@ns.declare
def font_files(*args):
    return _font_files(args, inline=False)


@ns.declare
def inline_font_files(*args):
    return _font_files(args, inline=True)


# ------------------------------------------------------------------------------
# External stylesheets

@ns.declare
def stylesheet_url(path, only_path=False, cache_buster=True):
    """
    Generates a path to an asset found relative to the project's css directory.
    Passing a true value as the second argument will cause the only the path to
    be returned instead of a `url()` function
    """
    filepath = String.unquoted(path).value
    if callable(config.STATIC_ROOT):
        try:
            _file, _storage = list(config.STATIC_ROOT(filepath))[0]
        except IndexError:
            filetime = None
        else:
            filetime = getmtime(_file, _storage)
        if filetime is None:
            filetime = 'NA'
    else:
        _path = os.path.join(config.STATIC_ROOT, filepath.strip('/'))
        filetime = getmtime(_path)
        if filetime is None:
            filetime = 'NA'
    BASE_URL = config.STATIC_URL

    url = '%s%s' % (BASE_URL, filepath)
    if cache_buster:
        url = add_cache_buster(url, filetime)
    if only_path:
        return String.unquoted(url)
    else:
        return Url.unquoted(url)